From 1b4baae5f2b5885746dc6778b182a910412abf05 Mon Sep 17 00:00:00 2001 From: "jerod.wilkerson" <30474318+jerodw@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:14:35 -0600 Subject: [PATCH] story-044: The documenter records what it changed Implemented by the l5 harness story workflow. --- .harness/docs/ARCHITECTURE.md | 2 +- prompts/documenter.md | 14 +- tests/test_artifact_schemas.py | 11 +- tests/test_attempt_archiving.py | 2 + tests/test_changed_files_records.py | 269 +++++++++++++++++++++++- tests/test_clean_clone_check.py | 2 + tests/test_config_keys_are_obeyed.py | 2 + tests/test_coordinator_contract.py | 3 + tests/test_escalation_resume.py | 2 + tests/test_escalation_summary.py | 2 + tests/test_execution_history.py | 2 + tests/test_foreign_work_refusal.py | 2 + tests/test_required_output_freshness.py | 2 + tests/test_rerun_refusal.py | 2 + tests/test_resume_guard.py | 2 + tests/test_retry_history.py | 2 + tests/test_retry_routing.py | 2 + tests/test_revert_baseline.py | 2 + tests/test_revert_check.py | 2 + tests/test_single_story_reader.py | 2 + tests/test_stage_baseline.py | 2 + tests/test_stage_output_ownership.py | 2 + tests/test_story_coordinator.py | 2 + tests/test_undeclared_config_keys.py | 2 + workflows/story-workflow.json | 6 +- 25 files changed, 335 insertions(+), 8 deletions(-) diff --git a/.harness/docs/ARCHITECTURE.md b/.harness/docs/ARCHITECTURE.md index 739023c..9b922cb 100644 --- a/.harness/docs/ARCHITECTURE.md +++ b/.harness/docs/ARCHITECTURE.md @@ -22,7 +22,7 @@ l5 is a level 3 agentic harness: a story execution system. The workflow defines `story-workflow.json` defines the execution structure: stage list (implementer, tester, verifier, documenter), the prompt and expected artifacts for each stage, the retry routing table (which category of defect returns execution to which stage), and the escalation rule (retries exhausted → escalate). It carries no retry ceiling: since story-028 `max_retries` lives only in `rules/execution-rules.json` — see "Where a retry goes" below. -A stage that writes to the repository declares an optional `changed_files` key naming its changed-files record: the implementer declares `changed-files.json`, the tester declares `tester-changed-files.json`. After any stage with this declaration completes, the coordinator checks that record against `blocked_paths` and escalates on violation — enforcement is driven by the workflow definition, with no stage names hard-coded in the coordinator. The documenter declares no record and is intentionally unchecked; enabling it later is a one-line workflow change. Both records share one schema definition (`modified`/`created`/`deleted` arrays), not two copies. +A stage that writes to the repository declares an optional `changed_files` key naming its changed-files record: the implementer declares `changed-files.json`, the tester declares `tester-changed-files.json`, and since story-044 the documenter declares `documenter-changed-files.json`. After any stage with this declaration completes, the coordinator checks that record against `blocked_paths` and escalates on violation — enforcement is driven by the workflow definition, with no stage names hard-coded in the coordinator. Adding the documenter proved that: the diff that enabled the check touched no file under `orchestration/`, only the stage's `outputs`, `changed_files` and `schemas` entries in the workflow and the prompt paragraph asking for the record. The cost landed instead in the fixtures — the record became a required artifact, so every fake runner in `tests/` that drives the shipped workflow through the documenter had to start writing it. The declaration turns on the required-artifact check and the schema check along with the blocked-path check; it does not bring `may_not_create`, a revert check or a stage baseline, which remain separate declarations the documenter does not carry. All three records share one schema definition (`modified`/`created`/`deleted` arrays), not three copies. The documenter's record is written outward and read by nothing: no stage runs after it, so unlike the implementer's record — injected into later templates through `{{changed_files}}` — it is checked and then only committed. A stage may also declare an optional `may_not_create` key, a list of repository-relative path prefixes it is not allowed to add files under. The implementer declares `["tests/"]`; no other stage does. After a stage that declares both `changed_files` and `may_not_create`, the coordinator reads that stage's own record and escalates when any entry in its **`created`** array falls under a declared prefix. `modified` and `deleted` are not examined by *this* check — the rule is about independence, not about directories: an implementer must be able to update an existing test whose call site its own signature change broke, but validation it authors itself checks what it built rather than what was asked. Since story-017 those two arrays are decided by the revert check below rather than left unexamined. As with blocked paths, no stage name and no prefix appears in orchestration code; both are read off the stage dict. diff --git a/prompts/documenter.md b/prompts/documenter.md index 3489236..8871951 100644 --- a/prompts/documenter.md +++ b/prompts/documenter.md @@ -16,11 +16,19 @@ Do not: - create tests, or - rewrite documentation sections the story did not affect. -When you finish, write this file to the run directory at {{run_dir}}: +When you finish, write these files to the run directory at {{run_dir}}: documentation-report.md: which documents you updated and why, or a statement that no documentation change was needed and why. +documenter-changed-files.json, your own record of every repository file +this stage touched — the documents you edited, and nothing another stage +edited. This is the record you write outward; it is not the injected +"Changed files" below, which is the implementer's record arriving inward. +It must satisfy this schema: + +{{changed_files_schema}} + [Workflow Layer] Documentation is architectural memory. Future planning agents load these documents before generating story plans, so record what they will need. @@ -36,7 +44,9 @@ injected content as authoritative. Story: {{story}} -Changed files: +Changed files — the implementer's record, injected inward for you to read. +It is not the record you are asked to write; that one is +documenter-changed-files.json, described above: {{changed_files}} Implementation summary: diff --git a/tests/test_artifact_schemas.py b/tests/test_artifact_schemas.py index bfb1394..aea82df 100644 --- a/tests/test_artifact_schemas.py +++ b/tests/test_artifact_schemas.py @@ -244,6 +244,7 @@ def test_an_extra_key_anywhere_validates(harness_root): VALID_CHANGED_FILES = {"modified": ["src/app.py"], "created": [], "deleted": []} VALID_TESTER_RECORD = {"modified": [], "created": ["tests/test_app.py"], "deleted": []} +VALID_DOCUMENTER_RECORD = {"modified": [], "created": [], "deleted": []} VALID_TEST_RESULTS = {"status": "passed", "tests_run": 1, "tests_passed": 1} VALID_VERDICT = { "status": "passed", @@ -281,6 +282,9 @@ def __call__(self, prompt, *, stage, cwd, log_path, permission_mode, model, self._payload("verification-result.json", VALID_VERDICT)) elif stage == "documenter": (self.run_dir / "documentation-report.md").write_text("n/a\n") + _write(self.run_dir / "documenter-changed-files.json", + self._payload("documenter-changed-files.json", + VALID_DOCUMENTER_RECORD)) return AgentResult(ok=True, result_text=f"{stage} ok") @@ -445,8 +449,11 @@ def test_prompts_this_story_leaves_alone_carry_no_schema_placeholders(harness_ro # planner.md left this list in story-008, which injects {{story_schema}} # there deliberately; tests/test_story_008_validation.py holds that # property now. The assertion is unchanged for the prompts still without - # schema injection. - for name in ("assist.md", "documenter.md", "harness-layer.md"): + # schema injection. documenter.md left the list in story-044, which gave + # the documenter a changed-files record and so injects + # {{changed_files_schema}} there deliberately; the stage/schema sweep above + # holds that property, because the documenter now declares a schemas map. + for name in ("assist.md", "harness-layer.md"): text = (harness_root / "prompts" / name).read_text() assert "_schema}}" not in text, name diff --git a/tests/test_attempt_archiving.py b/tests/test_attempt_archiving.py index d6e23fa..4ae25df 100644 --- a/tests/test_attempt_archiving.py +++ b/tests/test_attempt_archiving.py @@ -115,6 +115,8 @@ def __call__(self, prompt, *, stage, cwd, log_path, permission_mode, model, elif stage == "documenter": (self.run_dir / "documentation-report.md").write_text( f"Documented after attempt {self.attempt}.\n", encoding="utf-8") + write_json(self.run_dir / "documenter-changed-files.json", + {"modified": [], "created": [], "deleted": []}) return AgentResult(ok=True, result_text=f"{stage} done") diff --git a/tests/test_changed_files_records.py b/tests/test_changed_files_records.py index bf3117e..934573e 100644 --- a/tests/test_changed_files_records.py +++ b/tests/test_changed_files_records.py @@ -22,12 +22,17 @@ class StageRunner: def __init__(self, target_root: Path, story_id: str = "story-001", tester_record: dict | None = None, - write_tester_record: bool = True): + write_tester_record: bool = True, + documenter_record: dict | None = None, + write_documenter_record: bool = True): self.run_dir = target_root / ".harness" / "runs" / story_id self.tester_record = tester_record or { "modified": [], "created": ["tests/test_app.py"], "deleted": [] } self.write_tester_record = write_tester_record + self.documenter_record = documenter_record if documenter_record is not None \ + else {"modified": [], "created": [], "deleted": []} + self.write_documenter_record = write_documenter_record self.calls: list[str] = [] def __call__(self, prompt, *, stage, cwd, log_path, permission_mode, @@ -51,6 +56,9 @@ def __call__(self, prompt, *, stage, cwd, log_path, permission_mode, }) elif stage == "documenter": (self.run_dir / "documentation-report.md").write_text("n/a\n") + if self.write_documenter_record: + _write(self.run_dir / "documenter-changed-files.json", + self.documenter_record) return AgentResult(ok=True, result_text=f"{stage} ok") @@ -62,7 +70,11 @@ def test_workflow_declares_per_stage_changed_files_records(harness_root): assert "tester-changed-files.json" in stages["tester"]["outputs"] assert stages["implementer"]["changed_files"] == "changed-files.json" assert stages["tester"]["changed_files"] == "tester-changed-files.json" - assert "changed_files" not in stages["documenter"] + assert "documenter-changed-files.json" in stages["documenter"]["outputs"] + assert stages["documenter"]["changed_files"] == "documenter-changed-files.json" + assert stages["documenter"]["schemas"] == { + "documenter-changed-files.json": "changed-files", + } def test_tester_prompt_requires_tester_changed_files_record(harness_root): @@ -142,3 +154,256 @@ def test_enforcement_follows_declaration_not_stage_name(target_root, harness_roo code = story_coordinator.run_story("story-001", harness_copy, target_root, runner) assert code == 0 assert runner.calls == ["implementer", "tester", "verifier", "documenter"] + + +# -------------------------------------------------------------------------- +# story-044: the documenter records what it changed +# +# The documenter is the one stage that writes to the repository after the +# verdict, and until this story it was the only writing stage whose edits +# nothing checked. Enabling the check is one declaration on the stage, so +# these cases are written against a real coordinator run rather than against +# the workflow file: the workflow file says what was declared, and only a run +# says whether the declaration is what the coordinator acts on. +# -------------------------------------------------------------------------- + + +ALL_STAGES = ["implementer", "tester", "verifier", "documenter"] + +#: A record naming a path the rules block, used for both the tester's and the +#: documenter's blocked-path case so the two messages are comparable. +BLOCKED_RECORD = {"modified": ["rules/execution-rules.json"], + "created": [], "deleted": []} + + +def _changed_files_fields(harness_root: Path) -> list[str]: + schema = json.loads( + (harness_root / "schemas" / "changed-files.schema.json").read_text() + ) + return sorted(schema["properties"]) + + +def _fields_stated_in_prose(template: str, fields: list[str]) -> list[str]: + """Which schema field names the template states itself. + + The placeholder is removed first: the rendered prompt names every field, + because the schema is injected into it. The question is whether the + template restates them beside the injection. + """ + body = template.replace("{{changed_files_schema}}", "") + return [field for field in fields if field in body] + + +def _harness_without_documenter_declaration(harness_root: Path, + tmp_path: Path) -> Path: + """A copy of the harness in which the documenter declares no record. + + The pre-story harness, for the purpose of these cases: everything else — + the required-artifact check, the schema check, the blocked-path check — + is untouched, so a case that behaves differently here behaves differently + because of the declaration and nothing else. + """ + harness_copy = tmp_path / "harness-before-story-044" + for sub in ("prompts", "workflows", "rules", "schemas"): + shutil.copytree(harness_root / sub, harness_copy / sub) + workflow_path = harness_copy / "workflows" / "story-workflow.json" + workflow = json.loads(workflow_path.read_text()) + for stage in workflow["stages"]: + if stage["name"] == "documenter": + record = stage.pop("changed_files") + stage["outputs"] = [o for o in stage["outputs"] if o != record] + stage.pop("schemas", None) + _write(workflow_path, workflow) + return harness_copy + + +def test_documenter_prompt_requires_the_record_with_the_schema_injected(harness_root): + prompt = (harness_root / "prompts" / "documenter.md").read_text() + assert "documenter-changed-files.json" in prompt + assert "{{changed_files_schema}}" in prompt + + fields = _changed_files_fields(harness_root) + # The absence: the template restates no field of the schema it injects. + assert _fields_stated_in_prose(prompt, fields) == [] + # Two controls for that absence. The first is a template that does state + # the fields — the tester's, which lists them rather than injecting them — + # and the second is this same template with the schema pasted in where the + # placeholder stands. Both must be reported, or the check above is passing + # because it has stopped seeing field names at all. + tester_prompt = (harness_root / "prompts" / "tester.md").read_text() + assert _fields_stated_in_prose(tester_prompt, fields) == fields + pasted = prompt.replace( + "{{changed_files_schema}}", + (harness_root / "schemas" / "changed-files.schema.json").read_text(), + ) + assert _fields_stated_in_prose(pasted, fields) == fields + + +def test_documenter_prompt_separates_the_record_it_writes_from_the_one_injected(harness_root): + """{{changed_files}} still carries the implementer's record inward, and the + template says so where it stands, so the two cannot be confused.""" + prompt = (harness_root / "prompts" / "documenter.md").read_text() + assert "{{changed_files}}" in prompt + + request = prompt[prompt.index("documenter-changed-files.json"): + prompt.index("{{changed_files_schema}}")] + assert "outward" in request + assert "not the injected" in request + + injection = prompt[:prompt.index("{{changed_files}}")].rsplit("\n\n", 1)[-1] + assert "implementer" in injection + assert "inward" in injection + + +def test_documenter_record_reuses_the_one_changed_files_schema(harness_root): + """One definition of the record's shape, validated the same way for all + three stages that write one.""" + workflow = json.loads( + (harness_root / "workflows" / "story-workflow.json").read_text() + ) + stages = {s["name"]: s for s in workflow["stages"]} + documenter_schema = stages["documenter"]["schemas"]["documenter-changed-files.json"] + assert documenter_schema == stages["implementer"]["schemas"]["changed-files.json"] + assert documenter_schema == stages["tester"]["schemas"]["tester-changed-files.json"] + + # The absence: no schema file was added for the documenter's record. The + # control is a copy of schemas/ with exactly such a file planted, which + # the same scan must report. + def documenter_schemas(directory: Path) -> list[str]: + return sorted(p.name for p in directory.glob("*.schema.json") + if "documenter" in p.name) + + assert documenter_schemas(harness_root / "schemas") == [] + + +def test_documenter_schema_scan_reports_a_planted_schema_file(harness_root, tmp_path): + """The control for the absence asserted above, kept beside it.""" + copy = tmp_path / "schemas" + shutil.copytree(harness_root / "schemas", copy) + (copy / "documenter-changed-files.schema.json").write_text("{}\n") + planted = sorted(p.name for p in copy.glob("*.schema.json") + if "documenter" in p.name) + assert planted == ["documenter-changed-files.schema.json"] + + +def _sources_naming(directory: Path, name: str) -> list[str]: + return sorted(p.name for p in directory.rglob("*.py") + if name in p.read_text(encoding="utf-8")) + + +def test_no_orchestration_source_names_the_documenters_record(harness_root, tmp_path): + """The record name reaches the coordinator only off the loaded workflow. + + The absence is that no module under orchestration/ spells it; the control + is a copy of orchestration/ with the name planted in one module, which the + same scan reports. + """ + orchestration = harness_root / "orchestration" + assert _sources_naming(orchestration, "documenter-changed-files.json") == [] + + planted = tmp_path / "orchestration-with-the-name" + shutil.copytree(orchestration, planted) + (planted / "planted.py").write_text( + 'RECORD = "documenter-changed-files.json"\n', encoding="utf-8") + assert _sources_naming(planted, "documenter-changed-files.json") == ["planted.py"] + + +def test_documenter_writing_a_clean_record_completes_the_run(target_root, harness_root): + runner = StageRunner( + target_root, + documenter_record={"modified": [".harness/docs/ARCHITECTURE.md"], + "created": [], "deleted": []}, + ) + code = story_coordinator.run_story("story-001", harness_root, target_root, runner) + assert code == 0 + assert runner.calls == ALL_STAGES + assert (runner.run_dir / "completion-report.md").is_file() + assert json.loads((runner.run_dir / "state.json").read_text())["status"] == "completed" + + +def test_documenter_without_a_record_escalates_as_a_missing_artifact( + target_root, harness_root): + """Reported by the path that reports any other missing stage output: the + control is the tester's own missing-record escalation above, which reads + the same sentence with its own stage and artifact named.""" + runner = StageRunner(target_root, write_documenter_record=False) + code = story_coordinator.run_story("story-001", harness_root, target_root, runner) + assert code == 2 + assert runner.calls == ALL_STAGES + reason = story_coordinator.escalation_reason(runner.run_dir) + assert reason == ("documenter did not produce required artifacts: " + "documenter-changed-files.json") + assert not (runner.run_dir / "completion-report.md").is_file() + + +def test_documenter_naming_a_blocked_path_escalates(target_root, harness_root): + runner = StageRunner(target_root, documenter_record=BLOCKED_RECORD) + code = story_coordinator.run_story("story-001", harness_root, target_root, runner) + assert code == 2 + assert runner.calls == ALL_STAGES + reason = story_coordinator.escalation_reason(runner.run_dir) + assert reason == "documenter modified blocked path: rules/execution-rules.json" + + +def test_documenter_blocked_path_message_has_the_shape_the_tester_produces( + target_root, harness_root, tmp_path): + """The same sentence, differing only in the stage that produced it — the + two are rendered by one line of the coordinator, and this is what says so + from the outside.""" + # Copied before either run, so the second run starts from a target no run + # has touched rather than from one already carrying a story branch. + second_target = tmp_path / "second-target" + shutil.copytree(target_root, second_target) + + documenter_runner = StageRunner(target_root, documenter_record=BLOCKED_RECORD) + assert story_coordinator.run_story( + "story-001", harness_root, target_root, documenter_runner) == 2 + documenter_reason = story_coordinator.escalation_reason(documenter_runner.run_dir) + + tester_runner = StageRunner(second_target, tester_record=BLOCKED_RECORD) + assert story_coordinator.run_story( + "story-001", harness_root, second_target, tester_runner) == 2 + tester_reason = story_coordinator.escalation_reason(tester_runner.run_dir) + + assert documenter_reason == tester_reason.replace("tester", "documenter", 1) + + +def test_documenter_record_failing_the_schema_escalates_as_invalid( + target_root, harness_root): + runner = StageRunner( + target_root, + documenter_record={"modified": ".harness/docs/ARCHITECTURE.md"}, + ) + code = story_coordinator.run_story("story-001", harness_root, target_root, runner) + assert code == 2 + assert runner.calls == ALL_STAGES + reason = story_coordinator.escalation_reason(runner.run_dir) + assert reason.startswith( + "documenter wrote an invalid artifact: documenter-changed-files.json " + "does not match the changed-files schema" + ) + + +def test_documenter_enforcement_comes_from_the_declaration( + target_root, harness_root, tmp_path): + """The control for all three escalations above. + + Against a harness whose documenter declares no record — the shape this + stage had before this story — the same runs behave as they did then: a + documenter naming a blocked path completes, and a documenter writing no + record completes, because nothing required one. Neither escalation above + is therefore something the coordinator would have produced anyway. + """ + harness_copy = _harness_without_documenter_declaration(harness_root, tmp_path) + second_target = tmp_path / "target-without-record" + shutil.copytree(target_root, second_target) + + blocked_runner = StageRunner(target_root, documenter_record=BLOCKED_RECORD) + assert story_coordinator.run_story( + "story-001", harness_copy, target_root, blocked_runner) == 0 + assert blocked_runner.calls == ALL_STAGES + + silent_runner = StageRunner(second_target, write_documenter_record=False) + assert story_coordinator.run_story( + "story-001", harness_copy, second_target, silent_runner) == 0 + assert silent_runner.calls == ALL_STAGES diff --git a/tests/test_clean_clone_check.py b/tests/test_clean_clone_check.py index 8a92b69..a8eeb0d 100644 --- a/tests/test_clean_clone_check.py +++ b/tests/test_clean_clone_check.py @@ -261,6 +261,8 @@ def __call__(self, prompt, *, stage, cwd, log_path, permission_mode, model, elif stage == "documenter": (self.run_dir / "documentation-report.md").write_text( "No changes needed.\n", encoding="utf-8") + write_json(self.run_dir / "documenter-changed-files.json", + {"modified": [], "created": [], "deleted": []}) return AgentResult(ok=True, result_text=f"{stage} done") diff --git a/tests/test_config_keys_are_obeyed.py b/tests/test_config_keys_are_obeyed.py index ededb02..350f82e 100644 --- a/tests/test_config_keys_are_obeyed.py +++ b/tests/test_config_keys_are_obeyed.py @@ -455,6 +455,8 @@ def __call__(self, prompt, *, stage, cwd, log_path, permission_mode, model, elif stage == "documenter": (self.run_dir / "documentation-report.md").write_text( "No changes needed.\n", encoding="utf-8") + _write_json(self.run_dir / "documenter-changed-files.json", + {"modified": [], "created": [], "deleted": []}) elif stage == AUDIT_STAGE: (self.run_dir / AUDIT_ARTIFACT).write_text( "Audited.\n", encoding="utf-8") diff --git a/tests/test_coordinator_contract.py b/tests/test_coordinator_contract.py index 1d4fa4c..cc6abe9 100644 --- a/tests/test_coordinator_contract.py +++ b/tests/test_coordinator_contract.py @@ -382,6 +382,9 @@ def __call__(self, prompt, *, stage, cwd, log_path, permission_mode, model, elif stage == "documenter": (self.run_dir / "documentation-report.md").write_text( "No changes needed.\n", encoding="utf-8") + self._write_json("documenter-changed-files.json", { + "modified": [], "created": [], "deleted": [], + }) return AgentResult(ok=True, result_text=f"{stage} done") diff --git a/tests/test_escalation_resume.py b/tests/test_escalation_resume.py index 52027aa..705fda2 100644 --- a/tests/test_escalation_resume.py +++ b/tests/test_escalation_resume.py @@ -292,6 +292,8 @@ def __call__(self, prompt, *, stage, cwd=None, log_path=None, write_json(self.run_dir / "verification-result.json", verdict) elif stage == "documenter": write(self.run_dir / "documentation-report.md", "Nothing.\n") + write_json(self.run_dir / "documenter-changed-files.json", + {"modified": [], "created": [], "deleted": []}) return AgentResult(ok=True, result_text=f"{stage} done") diff --git a/tests/test_escalation_summary.py b/tests/test_escalation_summary.py index f0799b1..b4b019b 100644 --- a/tests/test_escalation_summary.py +++ b/tests/test_escalation_summary.py @@ -249,6 +249,8 @@ def __call__(self, prompt, *, stage, cwd=None, log_path=None, write_json(self.run_dir / "verification-result.json", verdict) elif stage == "documenter": write(self.run_dir / "documentation-report.md", "Nothing.\n") + write_json(self.run_dir / "documenter-changed-files.json", + {"modified": [], "created": [], "deleted": []}) return AgentResult(ok=True, result_text=f"{stage} done") diff --git a/tests/test_execution_history.py b/tests/test_execution_history.py index 3243c0b..d938481 100644 --- a/tests/test_execution_history.py +++ b/tests/test_execution_history.py @@ -117,6 +117,8 @@ def __call__(self, prompt, *, stage, cwd, log_path, permission_mode, model, elif stage == "documenter": (self.run_dir / "documentation-report.md").write_text( "No changes needed.\n", encoding="utf-8") + write_json(self.run_dir / "documenter-changed-files.json", + {"modified": [], "created": [], "deleted": []}) return AgentResult(ok=True, result_text=f"{stage} done") diff --git a/tests/test_foreign_work_refusal.py b/tests/test_foreign_work_refusal.py index 996d0a9..e3e57ed 100644 --- a/tests/test_foreign_work_refusal.py +++ b/tests/test_foreign_work_refusal.py @@ -266,6 +266,8 @@ def __call__(self, prompt, *, stage, cwd=None, log_path=None, write(self.target_root / DOC_OUTPUT, f"# Architecture\n\nDocumented on attempt {attempt}.\n") write(self.run_dir / "documentation-report.md", "Documented.\n") + write_json(self.run_dir / "documenter-changed-files.json", + {"modified": [DOC_OUTPUT], "created": [], "deleted": []}) return AgentResult(ok=True, result_text=f"{stage} done") diff --git a/tests/test_required_output_freshness.py b/tests/test_required_output_freshness.py index 42ba903..5f6751b 100644 --- a/tests/test_required_output_freshness.py +++ b/tests/test_required_output_freshness.py @@ -255,6 +255,8 @@ def _fresh(self, artifact: str, attempt: int, verdict: dict) -> str: return write_json(path, {"modified": [], "created": [f"tests/test_attempt_{attempt}.py"], "deleted": []}) + if artifact == "documenter-changed-files.json": + return write_json(path, {"modified": [], "created": [], "deleted": []}) if artifact == "test-results.json": return write_json(path, {"status": "passed", "tests_written": attempt, "tests_run": 5, "tests_passed": 5, diff --git a/tests/test_rerun_refusal.py b/tests/test_rerun_refusal.py index 03b17d5..465caf3 100644 --- a/tests/test_rerun_refusal.py +++ b/tests/test_rerun_refusal.py @@ -248,6 +248,8 @@ def __call__(self, prompt, *, stage, cwd=None, log_path=None, write(self.target_root / DOC_OUTPUT, f"# Architecture\n\nDocumented on attempt {attempt}.\n") write(self.run_dir / "documentation-report.md", "Documented.\n") + write_json(self.run_dir / "documenter-changed-files.json", + {"modified": [DOC_OUTPUT], "created": [], "deleted": []}) return AgentResult(ok=True, result_text=f"{stage} done") diff --git a/tests/test_resume_guard.py b/tests/test_resume_guard.py index 788a0c4..470442e 100644 --- a/tests/test_resume_guard.py +++ b/tests/test_resume_guard.py @@ -233,6 +233,8 @@ def __call__(self, prompt, *, stage, cwd=None, log_path=None, write_json(self.run_dir / "verification-result.json", self.verdict) elif stage == "documenter": write(self.run_dir / "documentation-report.md", "Nothing.\n") + write_json(self.run_dir / "documenter-changed-files.json", + {"modified": [], "created": [], "deleted": []}) return AgentResult(ok=True, result_text=f"{stage} done") diff --git a/tests/test_retry_history.py b/tests/test_retry_history.py index 2a0d092..476e68e 100644 --- a/tests/test_retry_history.py +++ b/tests/test_retry_history.py @@ -161,6 +161,8 @@ def __call__(self, prompt, *, stage, cwd, log_path, permission_mode, model, elif stage == "documenter": (self.run_dir / "documentation-report.md").write_text( f"Documented after attempt {self.attempt}.\n", encoding="utf-8") + write_json(self.run_dir / "documenter-changed-files.json", + {"modified": [], "created": [], "deleted": []}) return AgentResult(ok=True, result_text=f"{stage} done") diff --git a/tests/test_retry_routing.py b/tests/test_retry_routing.py index 583dc5a..77f066d 100644 --- a/tests/test_retry_routing.py +++ b/tests/test_retry_routing.py @@ -324,6 +324,8 @@ def __call__(self, prompt, *, stage, cwd=None, log_path=None, write_json(self.run_dir / "retry-guidance.json", GUIDANCE) elif stage == "documenter": write(self.run_dir / "documentation-report.md", "Nothing.\n") + write_json(self.run_dir / "documenter-changed-files.json", + {"modified": [], "created": [], "deleted": []}) return AgentResult(ok=True, result_text=f"{stage} done") diff --git a/tests/test_revert_baseline.py b/tests/test_revert_baseline.py index cb26294..ea65660 100644 --- a/tests/test_revert_baseline.py +++ b/tests/test_revert_baseline.py @@ -389,6 +389,8 @@ def __call__(self, prompt, *, stage, cwd=None, log_path=None, self._nth(self.verdicts, seen)) elif stage == "documenter": write(self.run_dir / "documentation-report.md", "Nothing.\n") + write_json(self.run_dir / "documenter-changed-files.json", + {"modified": [], "created": [], "deleted": []}) if self.interrupt == (stage, self.calls.count(stage)): raise KeyboardInterrupt(f"{stage} interrupted") return AgentResult(ok=True, result_text=f"{stage} done") diff --git a/tests/test_revert_check.py b/tests/test_revert_check.py index 819a02c..3c83036 100644 --- a/tests/test_revert_check.py +++ b/tests/test_revert_check.py @@ -305,6 +305,8 @@ def __call__(self, prompt, *, stage, cwd=None, log_path=None, write_json(self.run_dir / "verification-result.json", PASS) elif stage == "documenter": write(self.run_dir / "documentation-report.md", "Nothing.\n") + write_json(self.run_dir / "documenter-changed-files.json", + {"modified": [], "created": [], "deleted": []}) return AgentResult(ok=True, result_text=f"{stage} done") diff --git a/tests/test_single_story_reader.py b/tests/test_single_story_reader.py index 5328a4a..48f9da6 100644 --- a/tests/test_single_story_reader.py +++ b/tests/test_single_story_reader.py @@ -132,6 +132,8 @@ def __call__(self, prompt, *, stage, **kwargs): }) elif stage == "documenter": self._write("documentation-report.md", "Nothing to document.\n") + self._write("documenter-changed-files.json", + {"modified": [], "created": [], "deleted": []}) return AgentResult(ok=True, result_text=f"{stage} done") def _write(self, name: str, payload) -> None: diff --git a/tests/test_stage_baseline.py b/tests/test_stage_baseline.py index d4e9612..f98e39a 100644 --- a/tests/test_stage_baseline.py +++ b/tests/test_stage_baseline.py @@ -362,6 +362,8 @@ def __call__(self, prompt, *, stage, cwd=None, log_path=None, self._nth(self.verdicts, seen)) elif stage == "documenter": write(self.run_dir / "documentation-report.md", "Nothing.\n") + write_json(self.run_dir / "documenter-changed-files.json", + {"modified": [], "created": [], "deleted": []}) if self.interrupt == (stage, self.calls.count(stage)): raise KeyboardInterrupt(f"{stage} interrupted") return AgentResult(ok=True, result_text=f"{stage} done") diff --git a/tests/test_stage_output_ownership.py b/tests/test_stage_output_ownership.py index dcba7d2..9d486c9 100644 --- a/tests/test_stage_output_ownership.py +++ b/tests/test_stage_output_ownership.py @@ -84,6 +84,8 @@ def __call__(self, prompt, *, stage, cwd=None, log_path=None, write_json(self.run_dir / "verification-result.json", self.verdicts.pop(0)) elif stage == "documenter": (self.run_dir / "documentation-report.md").write_text("Nothing.\n") + write_json(self.run_dir / "documenter-changed-files.json", + {"modified": [], "created": [], "deleted": []}) return AgentResult(ok=True, result_text=f"{stage} done") diff --git a/tests/test_story_coordinator.py b/tests/test_story_coordinator.py index 5dd22d5..8c51dc2 100644 --- a/tests/test_story_coordinator.py +++ b/tests/test_story_coordinator.py @@ -63,6 +63,8 @@ def __call__(self, prompt, *, stage, cwd, log_path, permission_mode, model, }) elif stage == "documenter": (self.run_dir / "documentation-report.md").write_text("No changes needed.\n") + write_json(self.run_dir / "documenter-changed-files.json", + {"modified": [], "created": [], "deleted": []}) return AgentResult(ok=True, result_text=f"{stage} done") diff --git a/tests/test_undeclared_config_keys.py b/tests/test_undeclared_config_keys.py index 977d6f6..685b72a 100644 --- a/tests/test_undeclared_config_keys.py +++ b/tests/test_undeclared_config_keys.py @@ -149,6 +149,8 @@ def __call__(self, prompt, *, stage, cwd, log_path, permission_mode, model, elif stage == "documenter": (self.run_dir / "documentation-report.md").write_text( "No changes needed.\n", encoding="utf-8") + _write_json(self.run_dir / "documenter-changed-files.json", + {"modified": [], "created": [], "deleted": []}) return AgentResult(ok=True, result_text=f"{stage} done") diff --git a/workflows/story-workflow.json b/workflows/story-workflow.json index bc487ce..b289f46 100644 --- a/workflows/story-workflow.json +++ b/workflows/story-workflow.json @@ -55,7 +55,11 @@ { "name": "documenter", "prompt": "documenter.md", - "outputs": ["documentation-report.md"] + "outputs": ["documentation-report.md", "documenter-changed-files.json"], + "changed_files": "documenter-changed-files.json", + "schemas": { + "documenter-changed-files.json": "changed-files" + } } ], "escalation_rules": {