diff --git a/.replworks/ARCHITECTURE.md b/.replworks/ARCHITECTURE.md index 263385b..c58c925 100644 --- a/.replworks/ARCHITECTURE.md +++ b/.replworks/ARCHITECTURE.md @@ -67,12 +67,12 @@ Task Ledger Reader and Unverified Mark Scanner → Document Line Classifier - Recognize the command and its options. - Reject an unknown command or option before any coordinator runs. - Resolve the base branch name for check (the given value, otherwise the default defined in PRODUCT_SPEC.md). - - For init and sync, prompt for and validate the selected published AGENTS.md document. + - Use the single canonical AGENTS.md source for init and sync without reading stdin. - Produce assistance output (usage help and similar) itself, without a coordinator. - Render each Outcome into output lines. - Apply presentation colors to sync difference lines only when the output writer is a terminal. - Map each Outcome to an exit status. -- Inputs: command-line arguments and, for init, the user's document selection. +- Inputs: command-line arguments. - Outputs: output lines, exit status, one Request to one Coordinator. - Ownership boundaries: - Owns all printed text, line layout, line order, and the exit status. @@ -81,15 +81,14 @@ Task Ledger Reader and Unverified Mark Scanner → Document Line Classifier ### C2. Init Coordinator - Responsibilities: - - Obtain the Canonical Text for the selected init document first. - - When automatic-run is selected, obtain Helper Script Text before any write. + - 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). - - For standard selection, remove an existing helper script. For automatic-run selection, create the helper-script directory and file only when absent; never replace an existing helper script, and ensure the helper script has execute permission. + - 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. - Stop at the first write failure. -- Inputs: Request with the selected document source, Canonical Text fact, optional Helper Script Text fact, and Workspace presence facts. -- Outputs: Outcome with one `(item path, created | skipped)` entry per processed item, and execution errors. +- 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. - Does not perform I/O itself, print, or compare content. @@ -131,13 +130,14 @@ Task Ledger Reader and Unverified Mark Scanner → Document Line Classifier - Responsibilities: - Read the local AGENTS.md fact first. Record an execution error and stop when it is `Absent` or `Failed`. - - Only after that, obtain the Canonical Text for the selected sync document. Record an execution error and stop when it is `Failed`. + - Only after that, obtain the Canonical Text. Record an execution error and stop when it is `Failed`. - Compare local text and Canonical Text for exact equality. - - When equal: return `UpToDate` and make no write. - - When different: obtain the difference from the Difference Calculator, obtain Helper Script Text only for automatic-run selection, then replace AGENTS.md and adjust helper-script state, including ensuring execute permission on the automatic-run helper script. + - When equal: mark `UpToDate` and make no AGENTS.md write. + - Obtain Helper Script Text for every sync after confirming AGENTS.md exists and the canonical AGENTS.md was retrieved. + - When different, obtain the difference and replace AGENTS.md. Whether AGENTS.md is equal or different, replace the helper script with Helper Script Text and ensure execute permission. - When replacement fails: keep the computed difference in the Outcome and record an execution error. -- Inputs: Request with the selected document source, local AGENTS.md fact, Canonical Text fact, optional Helper Script Text fact, and helper-script presence fact. -- Outputs: Outcome with `UpToDate | Replaced(difference) | ReplaceFailed(difference)`, and execution errors. +- Inputs: Request with the canonical document source, local AGENTS.md fact, Canonical Text fact, Helper Script Text fact, and helper-script presence fact. +- Outputs: Outcome with AGENTS.md up-to-date/replaced state, optional difference, helper-script created/replaced state, and execution errors. - Ownership boundaries: - Owns the order of local check, retrieval, comparison, difference, replacement. - Does not perform I/O itself, compute the difference itself, or print. @@ -162,12 +162,12 @@ Task Ledger Reader and Unverified Mark Scanner → Document Line Classifier ### C7. Canonical Document Provider - Responsibilities: - - Obtain Canonical Text from the source selected by the request, and Helper Script Text from its published source when requested. The allowed sources are those listed in PRODUCT_SPEC.md. + - Obtain Canonical Text from the source defined by PRODUCT_SPEC.md, and Helper Script Text from its published source when requested. The allowed sources are those listed in PRODUCT_SPEC.md. - Return each text exactly as the source content, with no normalization. - Retrieve each requested source at most once per invocation. Later requests in the same invocation reuse the first result, including a failure. - Treat these as `Failed`: source unreachable, unsuccessful response, or an empty response from the source. - Keep the source's representation private. Other components never see it. - - Inputs: a source selection and, when needed, the helper-script URL. Init and sync supply the user's selection; commands without a selection use PRODUCT_SPEC.md's default canonical document. + - Inputs: a canonical source and, when needed, the helper-script URL. Init, sync, and check use the canonical source defined in PRODUCT_SPEC.md. - Outputs: `Present(text) | Failed(reason)`. - Ownership boundaries: - Owns all network access and the extraction of the Canonical Text. @@ -321,11 +321,9 @@ Task Ledger Reader and Unverified Mark Scanner → Document Line Classifier ```text Request → Init Coordinator - Command Interface → validated document selection - Canonical Document Provider(selection) → Canonical Text fact - [automatic-run] → Helper Script Text fact + Canonical Document Provider → Canonical Text fact + Helper Script Text fact [Failed] → Outcome(error), no write - [Present] → Workspace presence and writes for the standard items and helper-script state + [Present] → Workspace presence and writes for standard items and replaced helper script → Outcome(items, errors) → Command Interface → lines + status ``` @@ -357,16 +355,14 @@ Request(base branch) → Check Coordinator ```text Request → Sync Coordinator Workspace Read(AGENTS.md) → fact - Command Interface → validated document selection [Absent | Failed] → Outcome(error), no write - Canonical Document Provider(selection) → Canonical Text fact + Canonical Document Provider → Canonical Text fact [Failed] → Outcome(error), no write exact comparison - equal → Outcome(UpToDate) - different → Difference Calculator → difference - → [automatic-run] Helper Script Text fact - → Workspace Write: replace AGENTS.md and adjust helper-script state - → Outcome(Replaced | ReplaceFailed, difference) + equal → Outcome(UpToDate) + Helper Script Text fact + different → Difference Calculator → difference + Helper Script Text fact + [both] → Workspace Write: replace helper script; when different, replace AGENTS.md + → Outcome(UpToDate | Replaced | ReplaceFailed, difference, script state) → Command Interface → lines + status ``` @@ -405,7 +401,6 @@ Request → Sync Coordinator | Failure | Detected by | Contained by | Effect | | ------------------------------------------------------------- | ------------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------- | | Unknown command or option | Command Interface | Command Interface | Error line, no coordinator runs, no file touched | -| Init or sync input ends or fails before a valid selection | Command Interface | Command Interface | Error line, exit 2, no coordinator runs, no file touched | | Canonical Text cannot be obtained or extracted | Canonical Document Provider | Coordinator in use | init and sync: stop, no write. check: agents-canonical not evaluated, other rules still run | | Project Directory not in a repository, or base branch missing | Base Branch Reader | Check Coordinator | task-ids-unchanged, single-task-completion, no-unverified not evaluated. documents-exist and agents-canonical still run | | TASKS.md absent in working tree (check) | Project Workspace | Check Coordinator | Not an error. The three ledger-dependent rules are not evaluated | @@ -430,7 +425,7 @@ Request → Sync Coordinator - No rollback or transaction across multiple writes. - No automatic repair of rule violations by check. - No resolution or ranking of conflicts between documents. -- No interactive prompting during an invocation except the required document selection for init and sync. +- No interactive prompting during an invocation. - No pluggable output renderers or alternative output encodings. ## 11. Architectural Invariants @@ -441,7 +436,7 @@ Request → Sync Coordinator - Every repository access is performed by the Base Branch Reader and is read-only. - Every printed line and every exit status is produced by the Command Interface. - Every violation is produced by the Rule Evaluator from facts, and every execution error from a `Failed` fact or failed write. -- The Canonical Text used within one invocation is always one single retrieved result from the selected or default source. +- The Canonical Text used within one invocation is always one single retrieved result from the canonical source. - Rule evaluation order and output order are always the fixed orders defined here. - Task Entry and Mark recognition always follow the single shared line and fence definition. - No component retains information between invocations. @@ -456,7 +451,7 @@ Each entry: ambiguity → decision → why. - RD-04: Duplicate task IDs and "absent or incomplete on base" are ambiguous → entries are counted individually, and an ID is complete in base when any base entry with that ID is complete; task-ids-unchanged compares distinct IDs → keeps counts per entry (status totals) while keeping ID identity stable across branches. - RD-05: Whether agents-canonical normalizes content is undefined → exact comparison with no normalization, also used by sync → confirmed by the user; matches "identical" in sync. - RD-06: The canonical source's representation is not defined by this architecture → the Canonical Document Provider hides the response representation and returns the exact response text; "cannot be obtained" includes an unsuccessful response or unavailable response → source-specific representation handling stays inside the provider. -- RD-07: When the canonical text is retrieved is undefined → exactly once per invocation, using init's or sync's validated selection or the default document for check, and always attempted by init, sync (after the local check), and check → ERR-001 requires an error line from check in every case where retrieval fails, so retrieval cannot be skipped. +- RD-07: When the canonical text is retrieved is undefined → exactly once per invocation from the canonical source, and always attempted by init, sync (after the local check), and check → ERR-001 requires an error line from check in every case where retrieval fails, so retrieval cannot be skipped. - RD-08: init may meet an existing AGENTS.md while the canonical text is unobtainable → init obtains the Canonical Text first and writes nothing on failure, even when AGENTS.md already exists → ERR-001 applies unconditionally to init. - RD-09: sync with both an absent AGENTS.md and an unobtainable canonical source would produce two errors → the local check runs first and stops, so exactly one error line is produced and no network access is spent → keeps ERR-004 and ERR-001 each satisfied with one error line. - RD-10: Repository validity in check is only tied to TASKS.md → check always verifies repository and base branch validity, even when TASKS.md is absent in the working tree → confirmed by the user; ERR-002 is unconditional. diff --git a/.replworks/PRODUCT_SPEC.md b/.replworks/PRODUCT_SPEC.md index 9bcc302..a7bbb7d 100644 --- a/.replworks/PRODUCT_SPEC.md +++ b/.replworks/PRODUCT_SPEC.md @@ -36,13 +36,10 @@ - Command and options given on the command line. - Files in the project directory. - Content of TASKS.md on the base branch of the git repository of the project directory. -- Canonical AGENTS.md documents: - - https://www.repl.net/documents/agents.md - - https://www.repl.net/documents/agents-auto.md -- Automatic-run helper script: +- Canonical AGENTS.md document: https://www.repl.net/documents/agents.md +- Helper script: - https://www.repl.net/scripts/ask-human.sh -- Init document selection: the document selected interactively for the current `repl init` invocation. The selection is not persisted. -- Default canonical document: `https://www.repl.net/documents/agents.md`, used by commands that do not receive a document selection, currently `repl check`. +- `repl init` and `repl sync` always use the canonical AGENTS.md document and install the helper script from its published source. - Authoritative documents: the five files AGENTS.md, .replworks/PRODUCT_SPEC.md, .replworks/TECH_STACK.md, .replworks/ARCHITECTURE.md, .replworks/TASKS.md. - Task: an entry in TASKS.md that has a task ID and a completion state. - Task ID: the identifier of a task. @@ -57,7 +54,7 @@ - Text lines printed by commands. - Exit code of each command. - AGENTS.md file and .replworks/ directory created by repl init. -- `.replworks/scripts/ask-human.sh` created or removed by the selected init/sync mode. +- `.replworks/scripts/ask-human.sh` created or replaced by init and sync. - AGENTS.md file replaced by repl sync. ## Constraints @@ -67,11 +64,9 @@ ## External Systems - https://www.repl.net/documents/agents.md - - Relied behavior: serves the default canonical AGENTS.md content and the first init option. -- https://www.repl.net/documents/agents-auto.md - - Relied behavior: serves the automatic-run AGENTS.md content and the second init option. + - Relied behavior: serves the canonical AGENTS.md content used by init, sync, and check. - https://www.repl.net/scripts/ask-human.sh - - Relied behavior: serves the exact script content installed for automatic-run projects. + - Relied behavior: serves the exact script content installed by init and sync. - Git repository of the project directory - Relied behavior: provides the content of TASKS.md on the base branch. @@ -84,8 +79,8 @@ - EL-001: repl init - 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, skipped, or deleted item, each containing the item path. + - Input: none. + - Output: one line per created, skipped, or replaced item, each containing the item path. - Exit: 0 on completion, 2 on execution error. - EL-002: repl status - Purpose: show task progress. @@ -104,8 +99,8 @@ - Exit: not applicable. - EL-005: repl sync - Purpose: replace AGENTS.md with the canonical AGENTS.md. - - Input: an interactive selection of one of the two published AGENTS.md documents. - - Output: an indication that AGENTS.md is up to date, or a line-by-line difference between the local AGENTS.md and the canonical AGENTS.md. + - Input: none. + - Output: an indication that AGENTS.md is up to date or a line-by-line difference, plus the helper script's created or replaced state. - Exit: 0 on completion, 2 on execution error. ### Rule Identifiers @@ -120,16 +115,16 @@ ### init -- 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-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 selecting the standard AGENTS document MUST remove an existing `.replworks/scripts/ask-human.sh`, and selecting the automatic-run document MAY add execute permission to that helper script. -- 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 when the automatic-run document is selected. +- 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-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: When the automatic-run document is selected, 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 preserve the content of an existing file while ensuring that it has execute permission. -- FR-038: When the standard AGENTS document is selected, repl init MUST remove `.replworks/scripts/ask-human.sh` when it exists. +- 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. ### status @@ -158,29 +153,29 @@ ### sync -- FR-027: repl sync MUST present the user with exactly the same two document choices as repl init and use the selected document as its canonical content. -- FR-028: repl sync MUST NOT modify AGENTS.md when its content is identical to the selected document. -- FR-029: repl sync MUST print an indication that AGENTS.md is up to date when its content is identical to the selected document. -- FR-030: repl sync MUST print the line-by-line difference between the local AGENTS.md and the selected document when the contents differ. -- FR-031: repl sync MUST replace the content of AGENTS.md with the selected document's content when the contents differ. -- FR-039: When repl sync replaces AGENTS.md because its contents differ, selecting the automatic-run document MUST create the helper script from the exact script response when the script does not exist, and selecting the standard document MUST remove the helper script when it exists. -- FR-040: When AGENTS.md content is identical to the selected document, repl sync MUST NOT create, modify, or remove the helper script. -- FR-041: When repl init creates or manages `.replworks/scripts/ask-human.sh` for the automatic-run document, the helper script MUST have execute permission. -- FR-042: When repl sync replaces AGENTS.md for the automatic-run document, the helper script MUST have execute permission whether it is created or already exists. +- FR-027: repl sync MUST use `https://www.repl.net/documents/agents.md` as its canonical content without prompting for a selection. +- FR-028: repl sync MUST NOT modify AGENTS.md when its content is identical to the canonical document. +- FR-029: repl sync MUST print an indication that AGENTS.md is up to date when its content is identical to the canonical document. +- FR-030: repl sync MUST print the line-by-line difference between the local AGENTS.md and the canonical document when the contents differ. +- FR-031: repl sync MUST replace the content of AGENTS.md with the canonical document's content when the contents differ. +- FR-039: repl sync MUST create or replace `.replworks/scripts/ask-human.sh` with the exact published script response on every invocation. +- FR-040: repl sync MUST replace an existing helper script with the exact published script response, even when AGENTS.md is already up to date. +- FR-041: The helper script installed by repl init MUST have execute permission whether it is created or replaced. +- FR-042: The helper script installed by repl sync MUST have execute permission whether it is created or replaced. - FR-035: repl sync MUST render removed difference lines in red and added difference lines in green when its output is a terminal, and MUST render plain difference lines otherwise. ### all commands - FR-032: Every command MUST operate only on the project directory. -- FR-033: repl-cli MUST NOT access any network resource other than the two published AGENTS documents and `https://www.repl.net/scripts/ask-human.sh`. +- FR-033: repl-cli MUST NOT access any network resource other than `https://www.repl.net/documents/agents.md` and `https://www.repl.net/scripts/ask-human.sh`. - FR-034: Only repl init and repl sync MUST write files. ## User Flows -- Flow 1: the user runs repl init in an empty directory -> the user selects one of the two published documents -> AGENTS.md contains that document and .replworks/ exists -> the user runs repl check. +- Flow 1: the user runs repl init in an empty directory -> AGENTS.md contains the canonical document, the helper script is installed, and .replworks/ exists -> the user runs repl check. - Flow 2: the user runs repl status -> the user reads the number of complete tasks and the next task ID. - Flow 3: the user runs repl check in a continuous integration job -> the job fails when the exit code is not 0. -- Flow 4: the user runs repl sync -> the user selects a published document -> the user reads the difference -> AGENTS.md equals the selected document. +- Flow 4: the user runs repl sync -> the user reads the difference -> AGENTS.md and the helper script match their canonical sources. ## Error Conditions @@ -211,14 +206,8 @@ - repl-cli MUST NOT modify any file. - repl-cli MUST print an error line. - repl-cli MUST exit with code 2. -- ERR-006: The init input ends or fails before the user selects one of the two init documents. - - repl init MUST NOT create or modify any file. - - repl init MUST print an error line. - - repl init MUST exit with code 2. -- ERR-007: The sync input ends or fails before the user selects one of the two sync documents. - - repl sync MUST NOT modify AGENTS.md. - - repl sync MUST print an error line. - - repl sync MUST exit with code 2. +- ERR-006: Reserved; init does not read interactive input. +- ERR-007: Reserved; sync does not read interactive input. ## Non-Goals @@ -228,7 +217,7 @@ ## Acceptance Criteria -- 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-001: Given an empty directory, when repl init runs without input, then AGENTS.md exists with the canonical 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, .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) @@ -247,14 +236,14 @@ - AC-017: Given a TASKS.md that does not exist on the base branch, when repl check runs, then every complete task in the working tree is a newly complete task. (FR-022, EL-003) - AC-018: Given a project directory without TASKS.md, when repl check runs, then no line reports rules task-ids-unchanged, single-task-completion, or no-unverified. (FR-023, EL-003) - AC-019: Given a project directory, when repl check runs, then no file is modified. (FR-019, EL-003) -- AC-020: Given an AGENTS.md identical to the selected document, when repl sync runs, then the file is unmodified, the output indicates that AGENTS.md is up to date, and the exit code is 0. (FR-027, FR-028, FR-031, EL-005) -- AC-021: Given an AGENTS.md that differs from the selected document, when repl sync runs, then the output contains the line-by-line difference, AGENTS.md equals the selected document, and the exit code is 0. (FR-027, FR-029, FR-030, FR-031, EL-005) -- AC-041: Given an empty directory and automatic-run selection, repl init creates `.replworks/scripts/ask-human.sh` with the exact live script response. -- AC-042: Given an existing helper script and standard selection, repl init removes the helper script while leaving an existing AGENTS.md unchanged. -- AC-043: Given differing AGENTS.md content, repl sync creates the missing helper script for automatic-run selection or removes an existing helper script for standard selection. -- AC-044: Given identical AGENTS.md content, repl sync leaves the helper script state unchanged and does not request the script URL. -- AC-022: Given that the selected init document is unreachable, when repl init runs, then no file is created or modified, an error line is printed, and the exit code is 2. (ERR-001, EL-001) -- AC-023: Given that the selected sync document is unreachable, when repl sync runs, then AGENTS.md is unmodified, an error line is printed, and the exit code is 2. (ERR-001, EL-005) +- AC-020: Given an AGENTS.md identical to the canonical document, when repl sync runs, then AGENTS.md is unmodified, the output indicates that it is up to date, the helper script is replaced with the live source, and the exit code is 0. (FR-027 through FR-031, FR-039, FR-040, EL-005) +- AC-021: Given an AGENTS.md that differs from the canonical document, when repl sync runs, then the output contains the line-by-line difference, AGENTS.md equals the canonical document, the helper script is replaced with the live source, and the exit code is 0. (FR-027, FR-029 through FR-031, FR-039, FR-040, EL-005) +- AC-041: Given an empty directory, repl init creates `.replworks/scripts/ask-human.sh` with the exact live script response. +- AC-042: Given an existing helper script, repl init replaces its content with the exact live script response while leaving an existing AGENTS.md unchanged. +- AC-043: Given either identical or differing AGENTS.md content, repl sync replaces an existing helper script with the exact live response and ensures execute permission. +- AC-044: Given an existing helper script, repl sync replaces its content even when AGENTS.md is already up to date. +- AC-022: Given that the canonical document or helper script is unreachable, when repl init runs, then no file is created or modified, an error line is printed, and the exit code is 2. (ERR-001, EL-001) +- AC-023: Given that the canonical document or helper script is unreachable, when repl sync runs, then AGENTS.md is unmodified, an error line is printed, and the exit code is 2. (ERR-001, EL-005) - AC-024: Given that https://www.repl.net/documents/agents.md is unreachable and a violation of rule documents-exist exists, when repl check runs, then the violation is reported, an error line is printed, and the exit code is 2. (ERR-001, FR-026, EL-003) - AC-025: Given a directory that is not a git repository, when repl check runs, then rules documents-exist and agents-canonical are evaluated, an error line is printed, and the exit code is 2. (ERR-002, EL-003) - AC-026: Given a base branch that does not exist, when repl check runs, then an error line is printed and the exit code is 2. (ERR-002, EL-003, EL-004) @@ -262,13 +251,13 @@ - AC-028: Given a project directory without AGENTS.md, when repl sync runs, then no file is created, an error line is printed, and the exit code is 2. (ERR-004, EL-005) - AC-029: Given an unknown command, when repl-cli runs, then no file is modified, an error line is printed, and the exit code is 2. (ERR-005) - AC-030: Given an unknown option, when repl-cli runs, then no file is modified, an error line is printed, and the exit code is 2. (ERR-005) -- AC-031: Given any command, when it runs, then no network request is sent to any address other than the two published document URLs, and init and sync request only their selected URL. (FR-033) +- AC-031: Given any command, when it runs, then no network request is sent to any address other than the canonical document URL and helper script URL; init requests both, sync requests neither when AGENTS.md is absent and both otherwise, and check requests only the canonical document URL. (FR-033) - AC-032: Given repl status or repl check, when it runs, then no file is created, modified, or deleted. (FR-012, FR-019, FR-034) -- AC-033: Given an empty directory and a valid selection of `https://www.repl.net/documents/agents-auto.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-034: Given an empty directory and an invalid init selection followed by a valid selection, when repl init runs, then repl init presents the choices again, creates the selected document, and exits with code 0. (FR-001, FR-002, EL-001) -- AC-035: Given an empty directory and an init input that ends before a valid selection, when repl init runs, then no file is created or modified, an error line is printed, and the exit code is 2. (ERR-006, EL-001) -- 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-033: Given an empty directory, repl init installs the canonical AGENTS.md and helper script without reading stdin, and exits with code 0. (FR-001, FR-002, FR-037, EL-001) +- AC-034: Given any stdin contents, repl init does not prompt or require a selection and completes according to the canonical source results. (FR-001, FR-038, EL-001) +- AC-035: Given init input that is empty or unavailable, repl init still proceeds without reading it. (FR-038, EL-001) +- AC-036: Given an AGENTS.md, repl sync uses the canonical source without reading stdin and completes. (FR-027, EL-005) +- AC-037: Given sync input that is empty or unavailable, repl sync still proceeds without reading it. (FR-027, EL-005) +- 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) diff --git a/.replworks/TECH_STACK.md b/.replworks/TECH_STACK.md index 11bee83..886d9e2 100644 --- a/.replworks/TECH_STACK.md +++ b/.replworks/TECH_STACK.md @@ -6,7 +6,7 @@ - Module path MUST be `github.com/replworks/repl-cli`. - Dependency manager: Go modules. go.mod and go.sum MUST be committed. - github.com/spf13/cobra v1.x. MUST implement every command. -- Canonical documents and the automatic-run helper script MUST be retrieved as unmodified response text over HTTPS with the standard-library `net/http` client. The provider MUST support the two published Markdown document URLs and the helper-script URL defined in PRODUCT_SPEC.md. +- The canonical AGENTS.md document and helper script MUST be retrieved as unmodified response text over HTTPS with the standard-library `net/http` client, from the URLs defined in PRODUCT_SPEC.md. - github.com/aymanbagabas/go-udiff v0.x. MUST produce line-by-line diffs. - Go standard library MUST be used for: net/http, log/slog, os/exec, bufio, regexp, testing, net/http/httptest. - `git` executable, invoked through os/exec. MUST read TASKS.md from the base branch. diff --git a/AGENTS.md b/AGENTS.md index e3048c0..4b4eaa6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -191,28 +191,45 @@ For every task: 5. If the task touches a domain that is not covered by verified knowledge in PRODUCT_SPEC.md, TECH_STACK.md, or ARCHITECTURE.md: stop. Mark the relevant section UNVERIFIED (see UNVERIFIED_MARK). Do not implement against an UNVERIFIED section. Require explicit human confirmation before continuing. 6. Implement 7. Write unit tests for internal logic -8. If the task touches an EXTERNAL_BOUNDARY: write an E2E test against the live boundary. A mocked test alone does not satisfy this step. If the live boundary is unreachable, stop and report. Do not mark the task `[X]`. +8. If the task touches an EXTERNAL_BOUNDARY: write an E2E test against the live boundary. A mocked test alone does not satisfy this step. If the live boundary is unreachable, stop and report. Do not mark the task `[x]`. 9. Run all tests -10. Mark the completed task `[X]` in TASKS.md +10. Mark the completed task `[x]` in TASKS.md 11. Deliver and continue (see AUTO_RUN) --- ## AUTO_RUN +AUTO_RUN applies only to tasks from TASKS.md. +Any other change the human requests in the session is committed only after human review. + Repeat TASK_SELECTION and TASK_EXECUTION until no unchecked task remains. Do not stop between tasks. Do not ask for approval to start a task. -Accounts, commit author, and co-author are given by the human in the bootstrap prompt. -If they are missing, stop and ask. +Before the first git operation of a session, ask the human to confirm +the delivery identities. Fill in every value and ask in this form: + +Commit author/committer: +Co-authored-by: (only when the human took part in the change) +PR creator / assignee: +Merger: +Is this correct? + +Fill missing values using available context, including `gh` commands. Show the +complete values and ask: ". Is this correct?" Wait for the human's +confirmation before any git operation. Per task: 1. Pull main. 2. Create branch `t-NNN-slug`. 3. Implement and test (TASK_EXECUTION). -4. Commit as the human account. -5. Open a PR as the bot account. Reviewer: human account. Assignee: bot account. +4. Commit with the agent that wrote the code as author and committer + (name and noreply email of that agent). + Add a `Co-authored-by` trailer for the human account + when the human specified the change or answered a question about it, + in the session or on GitHub. Add no other `Co-authored-by` trailer. +5. Open a PR as the bot account. Assignee: bot account. 6. Squash merge only after every CI check on the PR has passed. Pending is not passed. If no CI is configured, there is no gate. 7. Pull main and start the next task. @@ -227,6 +244,7 @@ Stop and ask the human only when: an UNVERIFIED gap needs a decision documents conflict a task is invalidated, or a spec/task/stack/architecture change is needed +a human-requested change is awaiting review a live EXTERNAL_BOUNDARY is unreachable a task requires a Developer decision a manual or permission step is required @@ -344,7 +362,7 @@ When the human explicitly asks for such a change in the session: If the change would, stop (see PRODUCT_SPEC_CHANGES). 3. Do not record the change in TASKS.md or any other document. 4. Run the verification commands defined in TECH_STACK.md. -5. Deliver it like a task (branch, PR, merge; see AUTO_RUN). +5. Show the diff and test results. After explicit human approval, commit, open the PR, and merge by AUTO_RUN steps 4-6 (merge only after CI passes). 6. Stop. Never make such changes on your own initiative. diff --git a/internal/command/confine_e2e_test.go b/internal/command/confine_e2e_test.go index ba392bc..d3b5756 100644 --- a/internal/command/confine_e2e_test.go +++ b/internal/command/confine_e2e_test.go @@ -99,8 +99,8 @@ func TestInitInASubdirectoryCreatesFilesInTheProjectDirectoryOnly(t *testing.T) if code != 0 { t.Fatalf("exit code = %d, want 0 (stdout = %q, stderr = %q)", code, stdout, stderr) } - if got := transport.requested(); len(got) != 1 { - t.Fatalf("requests = %q, want the canonical text retrieved exactly once", got) + if got := transport.requested(); len(got) != 2 { + t.Fatalf("requests = %q, want canonical text and helper script", got) } for _, rel := range []string{"AGENTS.md", ".replworks"} { if _, err := os.Stat(filepath.Join(project, rel)); err != nil { diff --git a/internal/command/init.go b/internal/command/init.go index 0a95b23..849379b 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -1,14 +1,10 @@ package command import ( - "bufio" - "errors" "fmt" - "strings" "github.com/spf13/cobra" - "github.com/replworks/repl-cli/internal/canon" "github.com/replworks/repl-cli/internal/initproject" "github.com/replworks/repl-cli/internal/workspace" ) @@ -17,22 +13,17 @@ 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 into the project directory, creates the .replworks directory, and creates the four authoritative documents under it as empty files. Existing entries are left unchanged.", + 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.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - document, err := selectDocument(cmd) - if err != nil { - return err - } - dir, err := r.projectDir() if err != nil { return err } outcome := initproject.Coordinator{ - Source: r.canonical(document), - Script: initScriptSource(r, document), + Source: r.canonical(), + Script: r.askHumanScript(), ManageScript: true, Workspace: workspace.GrantWrite(workspace.New(dir)), }.Run() @@ -43,42 +34,6 @@ func (r *runner) newInitCommand() *cobra.Command { } } -func initScriptSource(r *runner, document canon.Document) initproject.CanonicalSource { - if document == canon.AgentsAutoDocument { - return r.askHumanScript() - } - - return nil -} - -func selectDocument(cmd *cobra.Command) (canon.Document, error) { - out := cmd.OutOrStdout() - scanner := bufio.NewScanner(cmd.InOrStdin()) - for { - _, _ = fmt.Fprintln(out, "Select an AGENTS.md document:") - _, _ = fmt.Fprintf(out, "1) %s\n", canon.AgentsDocument) - _, _ = fmt.Fprintf(out, "2) %s\n", canon.AgentsAutoDocument) - _, _ = fmt.Fprintln(out, "Choice [1-2]:") - - if !scanner.Scan() { - if err := scanner.Err(); err != nil { - return "", fmt.Errorf("reading the init document selection: %w", err) - } - - return "", errors.New("an init document selection is required") - } - - switch strings.TrimSpace(scanner.Text()) { - case "1": - return canon.AgentsDocument, nil - case "2": - return canon.AgentsAutoDocument, nil - default: - _, _ = fmt.Fprintln(out, "Invalid selection. Please enter 1 or 2.") - } - } -} - func renderInit(cmd *cobra.Command, outcome initproject.Outcome) int { out := cmd.OutOrStdout() @@ -86,6 +41,8 @@ func renderInit(cmd *cobra.Command, outcome initproject.Outcome) int { state := "skipped" if item.Created { state = "created" + } else if item.Replaced { + state = "replaced" } else if item.Deleted { state = "deleted" } diff --git a/internal/command/init_e2e_test.go b/internal/command/init_e2e_test.go index 3ebe4f4..8e0eb63 100644 --- a/internal/command/init_e2e_test.go +++ b/internal/command/init_e2e_test.go @@ -42,14 +42,14 @@ func liveDocument(t *testing.T, document canon.Document) string { return canonical.Value() } -func TestInitAgainstTheLiveAutomaticSource(t *testing.T) { - want := liveDocument(t, canon.AgentsAutoDocument) +func TestInitAgainstTheLiveCanonicalAndScriptSources(t *testing.T) { + want := liveCanonical(t) wantScript := liveAskHumanScript(t) dir := t.TempDir() t.Chdir(dir) - code, stdout, stderr := runCommandWithInput(t, nil, "2\n", "init") + code, stdout, stderr := runCommandWithInput(t, nil, "", "init") if code != 0 { t.Fatalf("exit code = %d, want 0 (stdout = %q, stderr = %q)", code, stdout, stderr) @@ -62,7 +62,7 @@ func TestInitAgainstTheLiveAutomaticSource(t *testing.T) { t.Fatalf("reading AGENTS.md: %v", err) } if string(content) != want { - t.Fatal("AGENTS.md does not hold the selected live AGENTS.md") + t.Fatal("AGENTS.md does not hold the live canonical AGENTS.md") } script, err := os.ReadFile(filepath.Join(dir, ".replworks/scripts/ask-human.sh")) if err != nil { @@ -257,8 +257,8 @@ func TestInitAgainstTheLiveSourceSkipsEveryExistingItem(t *testing.T) { if stderr != "" { t.Fatalf("stderr = %q, want empty", stderr) } - if after := directoryState(t, dir); after != before { - t.Fatalf("project directory changed:\nbefore: %s\nafter: %s", before, after) + if after := directoryState(t, dir); strings.ReplaceAll(after, "\n.replworks/scripts/\n.replworks/scripts/ask-human.sh="+liveAskHumanScript(t), "") != before { + t.Fatalf("project directory changed beyond installing the helper script:\nbefore: %s\nafter: %s", before, after) } for _, want := range []string{ diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 0c0ef07..addd3fe 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -132,14 +132,14 @@ func runCommandWithInput(t *testing.T, client *http.Client, input string, args . return app.Execute(args), stdout.String(), stderr.String() } -func TestInitUsesTheAutomaticDocumentWhenSelected(t *testing.T) { +func TestInitUsesTheCanonicalDocumentAndInstallsTheScriptWithoutSelection(t *testing.T) { dir := t.TempDir() t.Chdir(dir) want := "# AGENTS.md\n\n## AUTO_RUN\n" client, transport := servePage(t, want) - code, stdout, stderr := runCommandWithInput(t, client, "2\n", "init") + code, stdout, stderr := runCommandWithInput(t, client, "ignored input\n", "init") if code != 0 { t.Fatalf("exit code = %d, want 0 (stdout = %q, stderr = %q)", code, stdout, stderr) @@ -155,8 +155,8 @@ func TestInitUsesTheAutomaticDocumentWhenSelected(t *testing.T) { t.Fatalf("AGENTS.md = %q, want %q", content, want) } requests := transport.requested() - if len(requests) != 2 || requests[0] != string(canon.AgentsAutoDocument) || requests[1] != string(canon.AskHumanScript) { - t.Fatalf("requests = %q, want AGENTS AUTO followed by %q", requests, canon.AskHumanScript) + if len(requests) != 2 || requests[0] != string(canon.AgentsDocument) || requests[1] != string(canon.AskHumanScript) { + t.Fatalf("requests = %q, want canonical document followed by %q", requests, canon.AskHumanScript) } if content, err := os.ReadFile(filepath.Join(dir, ".replworks/scripts/ask-human.sh")); err != nil || string(content) != want { t.Fatalf("ask-human.sh = %q, err = %v, want %q", content, err, want) @@ -170,7 +170,7 @@ func TestInitUsesTheAutomaticDocumentWhenSelected(t *testing.T) { } } -func TestInitRepeatsAfterAnInvalidDocumentSelection(t *testing.T) { +func TestInitDoesNotReadInputForDocumentSelection(t *testing.T) { dir := t.TempDir() t.Chdir(dir) @@ -182,14 +182,14 @@ func TestInitRepeatsAfterAnInvalidDocumentSelection(t *testing.T) { if code != 0 { t.Fatalf("exit code = %d, want 0 (stdout = %q, stderr = %q)", code, stdout, stderr) } - if !strings.Contains(stdout, "Invalid selection. Please enter 1 or 2.") { - t.Fatalf("stdout = %q, want the invalid-selection message", stdout) + if strings.Contains(stdout, "Select an AGENTS.md document") || strings.Contains(stdout, "Invalid selection") { + t.Fatalf("stdout = %q, want no selection prompt", stdout) } if stderr != "" { t.Fatalf("stderr = %q, want empty", stderr) } - if got := transport.requested(); len(got) != 2 || got[0] != string(canon.AgentsAutoDocument) || got[1] != string(canon.AskHumanScript) { - t.Fatalf("requests = %q, want AGENTS AUTO followed by %q", got, canon.AskHumanScript) + if got := transport.requested(); len(got) != 2 || got[0] != string(canon.AgentsDocument) || got[1] != string(canon.AskHumanScript) { + t.Fatalf("requests = %q, want canonical document followed by %q", got, canon.AskHumanScript) } content, err := os.ReadFile(filepath.Join(dir, "AGENTS.md")) if err != nil { @@ -200,7 +200,7 @@ func TestInitRepeatsAfterAnInvalidDocumentSelection(t *testing.T) { } } -func TestInitMakesAnExistingAutomaticScriptExecutable(t *testing.T) { +func TestInitReplacesAnExistingScriptAndMakesItExecutable(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, ".replworks/scripts"), 0o755); err != nil { t.Fatalf("setup: %v", err) @@ -210,8 +210,8 @@ func TestInitMakesAnExistingAutomaticScriptExecutable(t *testing.T) { } t.Chdir(dir) - client, _ := servePage(t, "# AGENTS.md\n") - code, _, stderr := runCommandWithInput(t, client, "2\n", "init") + client, _ := servePage(t, "published script\n") + code, _, stderr := runCommandWithInput(t, client, "", "init") if code != 0 || stderr != "" { t.Fatalf("code = %d, stderr = %q, want success", code, stderr) @@ -223,28 +223,24 @@ func TestInitMakesAnExistingAutomaticScriptExecutable(t *testing.T) { if info.Mode().Perm()&0o111 == 0 { t.Fatalf("ask-human.sh permissions = %o, want an executable file", info.Mode().Perm()) } + content, err := os.ReadFile(filepath.Join(dir, ".replworks/scripts/ask-human.sh")) + if err != nil || string(content) != "published script\n" { + t.Fatalf("ask-human.sh = %q, err = %v, want published content", content, err) + } } -func TestInitRejectsMissingDocumentSelectionWithoutNetworkOrWrites(t *testing.T) { +func TestInitDoesNotRequireInput(t *testing.T) { dir := t.TempDir() t.Chdir(dir) client, transport := servePage(t, "# AGENTS.md\n") - before := directoryState(t, dir) - code, _, stderr := runCommandWithInput(t, client, "", "init") - if code != 2 { - t.Fatalf("exit code = %d, want 2", code) - } - if stderr == "" || !strings.Contains(stderr, "selection is required") { - t.Fatalf("stderr = %q, want the missing-selection error", stderr) + if code != 0 || stderr != "" { + t.Fatalf("exit code = %d, stderr = %q, want success without input", code, stderr) } - if got := transport.requested(); len(got) != 0 { - t.Fatalf("requests = %q, want none", got) - } - if after := directoryState(t, dir); after != before { - t.Fatalf("project directory changed:\nbefore: %s\nafter: %s", before, after) + if got := transport.requested(); len(got) != 2 { + t.Fatalf("requests = %q, want canonical document and script", got) } } @@ -291,12 +287,12 @@ func TestInitCreatesAgentsAndReplworks(t *testing.T) { if err != nil { t.Fatalf("reading .replworks: %v", err) } - if len(inside) != 4 { - t.Fatalf(".replworks holds %d entries, want 4", len(inside)) + if len(inside) != 5 { + t.Fatalf(".replworks holds %d entries, want 5 including scripts", len(inside)) } - if got := transport.requested(); len(got) != 1 { - t.Fatalf("requests = %q, want 1", got) + if got := transport.requested(); len(got) != 2 { + t.Fatalf("requests = %q, want canonical document and script", got) } documents := []string{ @@ -347,16 +343,11 @@ func TestInitLeavesExistingEntriesUnchanged(t *testing.T) { t.Chdir(dir) client, _ := servePage(t, string(recordedPage(t))) - before := directoryState(t, dir) - code, stdout, stderr := runCommand(t, client, "init") if code != 0 { t.Fatalf("exit code = %d, want 0 (stderr = %q)", code, stderr) } - if after := directoryState(t, dir); after != before { - t.Fatalf("project directory changed:\nbefore: %s\nafter: %s", before, after) - } for _, want := range []string{ "skipped: AGENTS.md", @@ -365,6 +356,7 @@ func TestInitLeavesExistingEntriesUnchanged(t *testing.T) { "skipped: .replworks/TECH_STACK.md", "skipped: .replworks/ARCHITECTURE.md", "skipped: .replworks/TASKS.md", + "created: .replworks/scripts/ask-human.sh", } { if !strings.Contains(stdout, want) { t.Fatalf("stdout = %q, want it to contain %q", stdout, want) @@ -431,7 +423,6 @@ func TestInitFailsWhenTheCanonicalSourceIsUnavailable(t *testing.T) { })) t.Cleanup(server.Close) client := &http.Client{Transport: &recordingTransport{target: server.URL}} - before := directoryState(t, dir) code, stdout, stderr := runCommand(t, client, "init") @@ -493,7 +484,6 @@ func TestInitSkipsEntriesOfAnyKind(t *testing.T) { t.Chdir(dir) client, _ := servePage(t, string(recordedPage(t))) - before := directoryState(t, dir) code, stdout, stderr := runCommand(t, client, "init") @@ -503,8 +493,8 @@ func TestInitSkipsEntriesOfAnyKind(t *testing.T) { if stderr != "" { t.Fatalf("stderr = %q, want empty", stderr) } - if after := directoryState(t, dir); after != before { - t.Fatalf("project directory changed:\nbefore: %s\nafter: %s", before, after) + if _, err := os.Stat(filepath.Join(dir, ".replworks/scripts/ask-human.sh")); err != nil { + t.Fatalf("ask-human.sh was not installed: %v", err) } for _, want := range []string{ @@ -606,7 +596,7 @@ func TestInitRejectsArguments(t *testing.T) { } } -func TestInitStandardSelectionRemovesTheAskHumanScript(t *testing.T) { +func TestInitReplacesExistingAskHumanScriptRegardlessOfInput(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, ".replworks/scripts"), 0o755); err != nil { t.Fatalf("setup: %v", err) @@ -625,13 +615,13 @@ func TestInitStandardSelectionRemovesTheAskHumanScript(t *testing.T) { if code != 0 || stderr != "" { t.Fatalf("code = %d, stdout = %q, stderr = %q", code, stdout, stderr) } - if _, err := os.Stat(filepath.Join(dir, ".replworks/scripts/ask-human.sh")); !os.IsNotExist(err) { - t.Fatalf("ask-human.sh stat error = %v, want deleted", err) + if content, err := os.ReadFile(filepath.Join(dir, ".replworks/scripts/ask-human.sh")); err != nil || string(content) != "# AGENTS.md\n" { + t.Fatalf("ask-human.sh = %q, err = %v, want replacement from the published source", content, err) } if content, err := os.ReadFile(filepath.Join(dir, "AGENTS.md")); err != nil || string(content) != "keep\n" { t.Fatalf("AGENTS.md = %q, err = %v, want unchanged", content, err) } - if !strings.Contains(stdout, "deleted: .replworks/scripts/ask-human.sh") { - t.Fatalf("stdout = %q, want deletion output", stdout) + if !strings.Contains(stdout, "replaced: .replworks/scripts/ask-human.sh") { + t.Fatalf("stdout = %q, want replacement output", stdout) } } diff --git a/internal/command/network_e2e_test.go b/internal/command/network_e2e_test.go index 7a51519..58aa390 100644 --- a/internal/command/network_e2e_test.go +++ b/internal/command/network_e2e_test.go @@ -15,12 +15,12 @@ func TestCommandsRequestOnlyTheCanonicalSource(t *testing.T) { name string agents string documents bool - wantRequests int + wantRequests []string }{ - {name: "init", wantRequests: 1}, - {name: "status", documents: true, wantRequests: 0}, - {name: "check", documents: true, wantRequests: 1}, - {name: "sync", agents: "# stale\n", documents: true, wantRequests: 1}, + {name: "init", wantRequests: []string{canon.SourceURL, string(canon.AskHumanScript)}}, + {name: "status", documents: true}, + {name: "check", documents: true, wantRequests: []string{canon.SourceURL}}, + {name: "sync", agents: "# stale\n", documents: true, wantRequests: []string{canon.SourceURL, string(canon.AskHumanScript)}}, } for _, tt := range tests { @@ -47,12 +47,12 @@ func TestCommandsRequestOnlyTheCanonicalSource(t *testing.T) { } requests := transport.requested() - if len(requests) != tt.wantRequests { - t.Fatalf("requests = %q, want %d", requests, tt.wantRequests) + if len(requests) != len(tt.wantRequests) { + t.Fatalf("requests = %q, want %q", requests, tt.wantRequests) } - for _, request := range requests { - if request != canon.SourceURL { - t.Fatalf("requested %q, want %q", request, canon.SourceURL) + for i, request := range requests { + if request != tt.wantRequests[i] { + t.Fatalf("requests = %q, want %q", requests, tt.wantRequests) } } }) diff --git a/internal/command/sync.go b/internal/command/sync.go index de224cd..5280878 100644 --- a/internal/command/sync.go +++ b/internal/command/sync.go @@ -7,7 +7,6 @@ import ( "github.com/spf13/cobra" - "github.com/replworks/repl-cli/internal/canon" "github.com/replworks/repl-cli/internal/diff" "github.com/replworks/repl-cli/internal/syncproject" "github.com/replworks/repl-cli/internal/workspace" @@ -20,20 +19,14 @@ func (r *runner) newSyncCommand() *cobra.Command { Long: "sync compares AGENTS.md with the canonical AGENTS.md, prints the line-by-line difference, and replaces AGENTS.md with the canonical content when the contents differ.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - document, err := selectDocument(cmd) - if err != nil { - return err - } - dir, err := r.projectDir() if err != nil { return err } outcome := syncproject.Coordinator{ - Source: r.canonical(document), - Script: syncScriptSource(r, document), - Auto: document == canon.AgentsAutoDocument, + Source: r.canonical(), + Script: r.askHumanScript(), ManageScript: true, Workspace: workspace.GrantWrite(workspace.New(dir)), Difference: diff.Calculator{}, @@ -45,14 +38,6 @@ func (r *runner) newSyncCommand() *cobra.Command { } } -func syncScriptSource(r *runner, document canon.Document) syncproject.CanonicalSource { - if document == canon.AgentsAutoDocument { - return r.askHumanScript() - } - - return nil -} - func renderSync(cmd *cobra.Command, outcome syncproject.Outcome) int { return renderSyncOutput(cmd, outcome, terminalOutput(cmd.OutOrStdout())) } @@ -62,8 +47,6 @@ func renderSyncOutput(cmd *cobra.Command, outcome syncproject.Outcome, color boo if outcome.UpToDate { _, _ = fmt.Fprintln(out, "AGENTS.md is up to date") - - return 0 } for _, entry := range outcome.Difference { diff --git a/internal/command/sync_e2e_test.go b/internal/command/sync_e2e_test.go index aab4182..943db02 100644 --- a/internal/command/sync_e2e_test.go +++ b/internal/command/sync_e2e_test.go @@ -8,12 +8,10 @@ import ( "path/filepath" "strings" "testing" - - "github.com/replworks/repl-cli/internal/canon" ) -func TestSyncAgainstTheLiveAutomaticSource(t *testing.T) { - want := liveDocument(t, canon.AgentsAutoDocument) +func TestSyncAgainstTheLiveCanonicalAndScriptSources(t *testing.T) { + want := liveCanonical(t) wantScript := liveAskHumanScript(t) dir := t.TempDir() @@ -22,7 +20,7 @@ func TestSyncAgainstTheLiveAutomaticSource(t *testing.T) { } t.Chdir(dir) - code, stdout, stderr := runCommandWithInput(t, nil, "2\n", "sync") + code, stdout, stderr := runCommandWithInput(t, nil, "", "sync") if code != 0 { t.Fatalf("exit code = %d, want 0 (stdout = %q, stderr = %q)", code, stdout, stderr) @@ -35,7 +33,7 @@ func TestSyncAgainstTheLiveAutomaticSource(t *testing.T) { t.Fatalf("reading AGENTS.md: %v", err) } if string(content) != want { - t.Fatal("AGENTS.md does not hold the selected live document") + t.Fatal("AGENTS.md does not hold the live canonical document") } script, err := os.ReadFile(filepath.Join(dir, ".replworks/scripts/ask-human.sh")) if err != nil { @@ -55,8 +53,6 @@ func TestSyncAgainstTheLiveSourceReportsUpToDate(t *testing.T) { } t.Chdir(dir) - before := directoryState(t, dir) - code, stdout, stderr := runCommand(t, nil, "sync") if code != 0 { @@ -68,8 +64,8 @@ func TestSyncAgainstTheLiveSourceReportsUpToDate(t *testing.T) { if !strings.Contains(stdout, "up to date") { t.Fatalf("stdout = %q, want an up-to-date indication", stdout) } - if after := directoryState(t, dir); after != before { - t.Fatalf("project directory changed:\nbefore: %s\nafter: %s", before, after) + if content, err := os.ReadFile(filepath.Join(dir, ".replworks/scripts/ask-human.sh")); err != nil || string(content) != liveAskHumanScript(t) { + t.Fatalf("ask-human.sh = %q, err = %v, want the live script", content, err) } } diff --git a/internal/command/sync_test.go b/internal/command/sync_test.go index ab49e12..a9aa12f 100644 --- a/internal/command/sync_test.go +++ b/internal/command/sync_test.go @@ -65,7 +65,7 @@ func TestRenderSyncLeavesDifferenceLinesPlainForNonTerminalOutput(t *testing.T) } } -func TestSyncUsesTheAutomaticDocumentWhenSelected(t *testing.T) { +func TestSyncUsesTheCanonicalDocumentAndInstallsTheScriptWithoutSelection(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("# stale\n"), 0o644); err != nil { t.Fatalf("setup: %v", err) @@ -75,7 +75,7 @@ func TestSyncUsesTheAutomaticDocumentWhenSelected(t *testing.T) { want := "# AGENTS.md\n\n## AUTO_RUN\n" client, transport := servePage(t, want) - code, stdout, stderr := runCommandWithInput(t, client, "2\n", "sync") + code, stdout, stderr := runCommandWithInput(t, client, "ignored input\n", "sync") if code != 0 { t.Fatalf("exit code = %d, want 0 (stdout = %q, stderr = %q)", code, stdout, stderr) @@ -91,8 +91,8 @@ func TestSyncUsesTheAutomaticDocumentWhenSelected(t *testing.T) { t.Fatalf("AGENTS.md = %q, want %q", content, want) } requests := transport.requested() - if len(requests) != 2 || requests[0] != string(canon.AgentsAutoDocument) || requests[1] != string(canon.AskHumanScript) { - t.Fatalf("requests = %q, want AGENTS AUTO followed by %q", requests, canon.AskHumanScript) + if len(requests) != 2 || requests[0] != string(canon.AgentsDocument) || requests[1] != string(canon.AskHumanScript) { + t.Fatalf("requests = %q, want canonical document followed by %q", requests, canon.AskHumanScript) } if content, err := os.ReadFile(filepath.Join(dir, ".replworks/scripts/ask-human.sh")); err != nil || string(content) != want { t.Fatalf("ask-human.sh = %q, err = %v, want %q", content, err, want) @@ -106,7 +106,7 @@ func TestSyncUsesTheAutomaticDocumentWhenSelected(t *testing.T) { } } -func TestSyncRepeatsAfterAnInvalidDocumentSelection(t *testing.T) { +func TestSyncDoesNotReadInputForDocumentSelection(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("# stale\n"), 0o644); err != nil { t.Fatalf("setup: %v", err) @@ -121,8 +121,8 @@ func TestSyncRepeatsAfterAnInvalidDocumentSelection(t *testing.T) { if code != 0 { t.Fatalf("exit code = %d, want 0 (stdout = %q, stderr = %q)", code, stdout, stderr) } - if !strings.Contains(stdout, "Invalid selection. Please enter 1 or 2.") { - t.Fatalf("stdout = %q, want the invalid-selection message", stdout) + if strings.Contains(stdout, "Select an AGENTS.md document") || strings.Contains(stdout, "Invalid selection") { + t.Fatalf("stdout = %q, want no selection prompt", stdout) } if stderr != "" { t.Fatalf("stderr = %q, want empty", stderr) @@ -157,34 +157,23 @@ func TestSyncMakesAnExistingAutomaticScriptExecutableWhenReplacingAgents(t *test } } -func TestSyncFailsWithoutASelectionBeforeNetworkOrWrite(t *testing.T) { +func TestSyncWithoutAGENTSDoesNotReadInputOrRequestNetwork(t *testing.T) { dir := t.TempDir() - local := "# stale\n" - if err := os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte(local), 0o644); err != nil { - t.Fatalf("setup: %v", err) - } t.Chdir(dir) client, transport := servePage(t, "# remote\n") - code, _, stderr := runCommandWithInput(t, client, "", "sync") + code, stdout, _ := runCommandWithInput(t, client, "", "sync") if code != 2 { t.Fatalf("exit code = %d, want 2", code) } - if !strings.Contains(stderr, "selection is required") { - t.Fatalf("stderr = %q, want the missing-selection error", stderr) + if !strings.Contains(stdout, "AGENTS.md does not exist") { + t.Fatalf("stdout = %q, want the missing-file error", stdout) } if got := transport.requested(); len(got) != 0 { t.Fatalf("requests = %q, want none", got) } - content, err := os.ReadFile(filepath.Join(dir, "AGENTS.md")) - if err != nil { - t.Fatalf("reading AGENTS.md: %v", err) - } - if string(content) != local { - t.Fatalf("AGENTS.md = %q, want unchanged %q", content, local) - } } func TestSyncReportsUpToDate(t *testing.T) { @@ -195,8 +184,6 @@ func TestSyncReportsUpToDate(t *testing.T) { t.Chdir(dir) client, transport := servePage(t, string(recordedPage(t))) - before := directoryState(t, dir) - code, stdout, stderr := runCommand(t, client, "sync") if code != 0 { @@ -208,11 +195,11 @@ func TestSyncReportsUpToDate(t *testing.T) { if !strings.Contains(stdout, "up to date") { t.Fatalf("stdout = %q, want an up-to-date indication", stdout) } - if after := directoryState(t, dir); after != before { - t.Fatalf("project directory changed:\nbefore: %s\nafter: %s", before, after) + if content, err := os.ReadFile(filepath.Join(dir, ".replworks/scripts/ask-human.sh")); err != nil || string(content) != recordedText(t) { + t.Fatalf("ask-human.sh = %q, err = %v, want the canonical live script source", content, err) } - if got := transport.requested(); len(got) != 1 { - t.Fatalf("requests = %q, want 1", got) + if got := transport.requested(); len(got) != 2 { + t.Fatalf("requests = %q, want canonical document and helper script", got) } } @@ -531,7 +518,7 @@ func TestRenderSync(t *testing.T) { } } -func TestSyncAutomaticSelectionDoesNotFetchOrChangeTheScriptWhenUpToDate(t *testing.T) { +func TestSyncReplacesTheScriptEvenWhenAgentsIsUpToDate(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, ".replworks/scripts"), 0o755); err != nil { t.Fatalf("setup: %v", err) @@ -546,17 +533,17 @@ func TestSyncAutomaticSelectionDoesNotFetchOrChangeTheScriptWhenUpToDate(t *test t.Chdir(dir) client, transport := servePage(t, "script\n") - code, stdout, stderr := runCommandWithInput(t, client, "2\n", "sync") + code, stdout, stderr := runCommandWithInput(t, client, "", "sync") if code != 0 || stderr != "" || !strings.Contains(stdout, "up to date") { t.Fatalf("code = %d, stdout = %q, stderr = %q", code, stdout, stderr) } requests := transport.requested() - if len(requests) != 1 || requests[0] != string(canon.AgentsAutoDocument) { - t.Fatalf("requests = %q, want only %q", requests, canon.AgentsAutoDocument) + if len(requests) != 2 || requests[0] != string(canon.AgentsDocument) || requests[1] != string(canon.AskHumanScript) { + t.Fatalf("requests = %q, want canonical document and helper script", requests) } content, err := os.ReadFile(filepath.Join(dir, ".replworks/scripts/ask-human.sh")) - if err != nil || string(content) != "keep\n" { - t.Fatalf("ask-human.sh = %q, err = %v, want unchanged", content, err) + if err != nil || string(content) != "script\n" { + t.Fatalf("ask-human.sh = %q, err = %v, want published contents", content, err) } } diff --git a/internal/initproject/initproject.go b/internal/initproject/initproject.go index 635a5a0..81816de 100644 --- a/internal/initproject/initproject.go +++ b/internal/initproject/initproject.go @@ -40,13 +40,15 @@ type Writer interface { MakeExecutable(rel string) error CreateDirectory(rel string) error DeleteFile(rel string) error + ReplaceFile(rel, content string) error } // Item reports one managed entry. type Item struct { - Path string - Created bool - Deleted bool + Path string + Created bool + Deleted bool + Replaced bool } type Outcome struct { @@ -67,16 +69,11 @@ func (c Coordinator) Run() Outcome { return Outcome{Errors: []error{canonicalReason(canonical)}} } - scriptExists := false scriptContent := "" if c.ManageScript { - exists := c.Workspace.Exists(askHumanPath) - if exists.IsFailed() { - return Outcome{Errors: []error{exists.Reason()}} + if c.Script == nil { + return Outcome{Errors: []error{errors.New("the ask-human.sh source is unavailable")}} } - scriptExists = exists.Value() - } - if c.ManageScript && c.Script != nil && !scriptExists { script := c.Script.Canonical() if !script.IsPresent() { return Outcome{Errors: []error{canonicalReason(script)}} @@ -115,17 +112,6 @@ func (c Coordinator) Run() Outcome { outcome.Items = append(outcome.Items, Item{Path: path, Created: true}) } - if c.ManageScript && c.Script == nil { - if scriptExists { - if err := c.Workspace.DeleteFile(askHumanPath); err != nil { - outcome.Errors = append(outcome.Errors, err) - return outcome - } - outcome.Items = append(outcome.Items, Item{Path: askHumanPath, Deleted: true}) - } - return outcome - } - if !c.ManageScript { return outcome } @@ -145,12 +131,21 @@ func (c Coordinator) Run() Outcome { outcome.Items = append(outcome.Items, Item{Path: scriptsPath, Created: true}) } - if scriptExists { + scriptExists := c.Workspace.Exists(askHumanPath) + if scriptExists.IsFailed() { + outcome.Errors = append(outcome.Errors, scriptExists.Reason()) + return outcome + } + if scriptExists.Value() { + if err := c.Workspace.ReplaceFile(askHumanPath, scriptContent); err != nil { + outcome.Errors = append(outcome.Errors, err) + return outcome + } if err := c.Workspace.MakeExecutable(askHumanPath); err != nil { outcome.Errors = append(outcome.Errors, err) return outcome } - outcome.Items = append(outcome.Items, Item{Path: askHumanPath}) + outcome.Items = append(outcome.Items, Item{Path: askHumanPath, Replaced: true}) return outcome } if err := c.Workspace.CreateFile(askHumanPath, scriptContent); err != nil { diff --git a/internal/initproject/initproject_test.go b/internal/initproject/initproject_test.go index a95b8ce..7437542 100644 --- a/internal/initproject/initproject_test.go +++ b/internal/initproject/initproject_test.go @@ -87,6 +87,14 @@ func (f *fakeWriter) DeleteFile(rel string) error { return nil } +func (f *fakeWriter) ReplaceFile(rel, content string) error { + f.events = append(f.events, "replace-file "+rel) + f.files[rel] = content + f.present[rel] = true + + return nil +} + func (f *fakeWriter) trace() string { return strings.Join(f.events, ", ") } @@ -288,8 +296,8 @@ func TestRunDoesNotWriteWhenTheAutomaticScriptCannotBeObtained(t *testing.T) { if len(outcome.Errors) != 1 || !errors.Is(outcome.Errors[0], failure) { t.Fatalf("errors = %v, want the helper-script failure", outcome.Errors) } - if len(writer.events) != 1 || writer.events[0] != "exists .replworks/scripts/ask-human.sh" { - t.Fatalf("events = %q, want only the helper-script presence check", writer.events) + if len(writer.events) != 0 { + t.Fatalf("events = %q, want no workspace access before both sources are obtained", writer.events) } } diff --git a/internal/syncproject/syncproject.go b/internal/syncproject/syncproject.go index 306a253..45fdd5a 100644 --- a/internal/syncproject/syncproject.go +++ b/internal/syncproject/syncproject.go @@ -51,7 +51,6 @@ type Outcome struct { type Coordinator struct { Source CanonicalSource Script CanonicalSource - Auto bool ManageScript bool Workspace Workspace Difference DifferenceCalculator @@ -72,87 +71,78 @@ func (c Coordinator) Run() Outcome { return Outcome{Errors: []error{canonicalReason(canonical)}} } - if local.Value() == canonical.Value() { - return Outcome{UpToDate: true} + upToDate := local.Value() == canonical.Value() + var difference []diff.Entry + if !upToDate { + difference = c.Difference.Compare(local.Value(), canonical.Value()) } - difference := c.Difference.Compare(local.Value(), canonical.Value()) - - scriptExists := false scriptContent := "" if c.ManageScript { - exists := c.Workspace.Exists(askHumanPath) - if exists.IsFailed() { - return Outcome{Difference: difference, Errors: []error{exists.Reason()}} + if c.Script == nil { + return Outcome{UpToDate: upToDate, Difference: difference, Errors: []error{errors.New("the ask-human.sh source is unavailable")}} } - scriptExists = exists.Value() - } - if c.ManageScript && c.Auto && c.Script != nil && !scriptExists { script := c.Script.Canonical() if !script.IsPresent() { - return Outcome{Difference: difference, Errors: []error{canonicalReason(script)}} + return Outcome{UpToDate: upToDate, Difference: difference, Errors: []error{canonicalReason(script)}} } scriptContent = script.Value() } - if err := c.Workspace.ReplaceFile(agentsPath, canonical.Value()); err != nil { - return Outcome{Difference: difference, Errors: []error{err}} - } - - if c.ManageScript && !c.Auto { - if scriptExists { - if err := c.Workspace.DeleteFile(askHumanPath); err != nil { - return Outcome{Difference: difference, Errors: []error{err}} - } - return Outcome{Difference: difference, ScriptPath: askHumanPath, ScriptState: "deleted"} - } - } - if c.ManageScript && c.Auto && !scriptExists { - return c.ensureScript(difference, scriptContent) - } - if c.ManageScript && c.Auto { - if err := c.Workspace.MakeExecutable(askHumanPath); err != nil { + if !upToDate { + if err := c.Workspace.ReplaceFile(agentsPath, canonical.Value()); err != nil { return Outcome{Difference: difference, Errors: []error{err}} } } - return Outcome{Difference: difference} + if c.ManageScript { + return c.writeScript(upToDate, difference, scriptContent) + } + + return Outcome{UpToDate: upToDate, Difference: difference} } -func (c Coordinator) ensureScript(difference []diff.Entry, content string) Outcome { +func (c Coordinator) writeScript(upToDate bool, difference []diff.Entry, content string) Outcome { exists := c.Workspace.Exists(askHumanPath) if exists.IsFailed() { - return Outcome{Difference: difference, Errors: []error{exists.Reason()}} - } - if exists.Value() { - return Outcome{Difference: difference} + return Outcome{UpToDate: upToDate, Difference: difference, Errors: []error{exists.Reason()}} } - directory := c.Workspace.Exists(scriptsPath) - if directory.IsFailed() { - return Outcome{Difference: difference, Errors: []error{directory.Reason()}} - } - if !directory.Value() { - root := c.Workspace.Exists(replworksPath) - if root.IsFailed() { - return Outcome{Difference: difference, Errors: []error{root.Reason()}} + if !exists.Value() { + directory := c.Workspace.Exists(scriptsPath) + if directory.IsFailed() { + return Outcome{UpToDate: upToDate, Difference: difference, Errors: []error{directory.Reason()}} } - if !root.Value() { - if err := c.Workspace.CreateDirectory(replworksPath); err != nil { - return Outcome{Difference: difference, Errors: []error{err}} + if !directory.Value() { + root := c.Workspace.Exists(replworksPath) + if root.IsFailed() { + return Outcome{UpToDate: upToDate, Difference: difference, Errors: []error{root.Reason()}} + } + if !root.Value() { + if err := c.Workspace.CreateDirectory(replworksPath); err != nil { + return Outcome{UpToDate: upToDate, Difference: difference, Errors: []error{err}} + } + } + if err := c.Workspace.CreateDirectory(scriptsPath); err != nil { + return Outcome{UpToDate: upToDate, Difference: difference, Errors: []error{err}} } - } - if err := c.Workspace.CreateDirectory(scriptsPath); err != nil { - return Outcome{Difference: difference, Errors: []error{err}} } } - if err := c.Workspace.CreateFile(askHumanPath, content); err != nil { - return Outcome{Difference: difference, Errors: []error{err}} + state := "replaced" + if exists.Value() { + if err := c.Workspace.ReplaceFile(askHumanPath, content); err != nil { + return Outcome{UpToDate: upToDate, Difference: difference, Errors: []error{err}} + } + } else { + state = "created" + if err := c.Workspace.CreateFile(askHumanPath, content); err != nil { + return Outcome{UpToDate: upToDate, Difference: difference, Errors: []error{err}} + } } if err := c.Workspace.MakeExecutable(askHumanPath); err != nil { - return Outcome{Difference: difference, Errors: []error{err}} + return Outcome{UpToDate: upToDate, Difference: difference, Errors: []error{err}} } - return Outcome{Difference: difference, ScriptPath: askHumanPath, ScriptState: "created"} + return Outcome{UpToDate: upToDate, Difference: difference, ScriptPath: askHumanPath, ScriptState: state} } func canonicalReason(canonical fact.Fact[string]) error { diff --git a/internal/syncproject/syncproject_test.go b/internal/syncproject/syncproject_test.go index 814dafc..4efd96e 100644 --- a/internal/syncproject/syncproject_test.go +++ b/internal/syncproject/syncproject_test.go @@ -176,7 +176,6 @@ func TestRunDoesNotReplaceAGENTSWhenTheAutomaticScriptCannotBeObtained(t *testin failure := errors.New("the helper script is unreachable") coord, _, workspace := coordinator(fact.Present("old\n"), fact.Present("new\n")) coord.ManageScript = true - coord.Auto = true coord.Script = &fakeSource{canonical: fact.Failed[string](failure)} outcome := coord.Run()