Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 47 additions & 14 deletions .harness/docs/ARCHITECTURE.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions orchestration/context_assembler.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,10 @@ def build_context(
"run_dir": str(run_dir),
"changed_files": _read(run_dir / "changed-files.json"),
"tester_changed_files": _read(run_dir / "tester-changed-files.json"),
"documenter_changed_files": _read(
run_dir / "documenter-changed-files.json"
),
"documentation_report": _read(run_dir / "documentation-report.md"),
"implementation_summary": _read(run_dir / "implementation-summary.md"),
"test_results": _read(run_dir / "test-results.json"),
"verification_result": _read(run_dir / "verification-result.json"),
Expand Down
14 changes: 12 additions & 2 deletions orchestration/story_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -934,7 +934,8 @@ def append_retry_record(
# commits the tree after every check the workflow performs. Everything below
# runs the same suite once more where the code actually ships — a fresh clone
# of the repository with the story committed into it — after the verifier
# passes and before the documenter runs.
# passes. Since story-045 the verifier is the workflow's last stage, so the
# tree it clones already holds the documenter's edits.
# --------------------------------------------------------------------------

#: How much of the run's combined output the record keeps. Enough to identify
Expand Down Expand Up @@ -2501,7 +2502,16 @@ def _complete(run_dir: Path, state: RunState, story: dict, target_root: Path) ->
)
(run_dir / "completion-report.md").write_text(report, encoding="utf-8")
_git(target_root, "add", "-A")
_git(target_root, "commit", "-m", completion_commit_message(state, title))
# `--allow-empty`, for the reason the escalation commits carry it: the
# commit is how a finished run is recognised — completion_commits reads it,
# and the pre-flight that refuses a re-run onto a finished branch reads
# that — so a run whose last stage changed no repository file must still
# leave one. Before story-045 the documenter ran last and all but
# guaranteed a dirty tree here; with the verifier last, a run that entered
# at it writes only run-directory artifacts, which a repository ignoring
# its run directory has nothing to commit from.
_git(target_root, "commit", "--allow-empty", "-m",
completion_commit_message(state, title))
append_event(
run_dir,
f"story completed on branch {state.branch}",
Expand Down
3 changes: 3 additions & 0 deletions prompts/documenter.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ after failing mechanically. The coordinator wrote it, not an agent: no
verifier has judged this work, and it says what was missing or stale:
{{self_route_result}}

Retry guidance:
{{retry_guidance}}

Retry lessons (retry history for this run):
{{retry_state}}

Expand Down
11 changes: 11 additions & 0 deletions prompts/verifier.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ You are a verification agent.

Your responsibilities are to:
- evaluate implementation behavior against the acceptance criteria,
- evaluate the documentation written for this story — the documentation
report and the documenter's changed-files record below are part of what
you judge, and a claim a document makes is held to the same evidence
standard as any other claim,
- identify incomplete execution,
- identify violations of the repository standards, and
- produce evidence-backed findings.
Expand Down Expand Up @@ -93,6 +97,13 @@ the tester stage; treat them as expected additions of a later stage, not
implementation scope violations):
{{tester_changed_files}}

Documenter changed files (documenter's record — documentation files created
or modified by the documenter stage):
{{documenter_changed_files}}

Documentation report (the documenter's account of what it wrote and why):
{{documentation_report}}

Implementation summary:
{{implementation_summary}}

Expand Down
4 changes: 2 additions & 2 deletions tests/test_artifact_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ def test_verdict_missing_a_routed_field_escalates_instead_of_routing(
)
code = story_coordinator.run_story("story-001", harness_root, target_root, runner)
assert code == 2
assert runner.calls == ["implementer", "tester", "verifier"]
assert runner.calls == ["implementer", "tester", "documenter", "verifier"]

state = json.loads((runner.run_dir / "state.json").read_text())
assert state["status"] == "escalated"
Expand Down Expand Up @@ -375,7 +375,7 @@ def test_extra_keys_on_every_artifact_do_not_stop_a_run(target_root, harness_roo
)
code = story_coordinator.run_story("story-001", harness_root, target_root, runner)
assert code == 0
assert runner.calls == ["implementer", "tester", "verifier", "documenter"]
assert runner.calls == ["implementer", "tester", "documenter", "verifier"]
assert not (runner.run_dir / "escalation-summary.md").exists()


Expand Down
35 changes: 26 additions & 9 deletions tests/test_attempt_archiving.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,14 @@ def read_state(run_dir: Path) -> dict:
return json.loads((run_dir / "state.json").read_text(encoding="utf-8"))


#: What a superseded attempt leaves behind, sorted as the archive lists it.
#: The documenter's two artifacts joined it in story-045: the documenter now
#: runs before the verifier, so an attempt that fails verification has already
#: produced them.
ATTEMPT_1_ARTIFACTS = [
"changed-files.json",
"documentation-report.md",
"documenter-changed-files.json",
"implementation-summary.md",
"retry-guidance.json",
"test-results.json",
Expand All @@ -151,7 +157,7 @@ def retry_then_pass(target_root, harness_root):
return runner, run_dir_of(target_root)


def test_attempt_1_archive_holds_the_six_artifacts_under_canonical_names(
def test_attempt_1_archive_holds_every_stage_artifact_under_canonical_names(
retry_then_pass,
):
_, run_dir = retry_then_pass
Expand Down Expand Up @@ -201,15 +207,26 @@ def test_the_archive_copies_rather_than_moves(retry_then_pass):
assert (run_dir / name).is_file(), name


def test_an_artifact_the_attempt_did_not_write_is_skipped(retry_then_pass):
"""The documenter never runs before a retry, so its report is absent from
the attempt-1 archive - skipped, not an archive failure."""
def test_the_documenters_artifacts_are_archived_with_the_attempt(retry_then_pass):
"""Since story-045 the documenter runs before the verifier, so a failed
attempt has already written its report and its record: both are archived
rather than skipped, and both are still at the run-directory root.

What this case used to assert - an archivable artifact the attempt did not
write is skipped rather than failing the archive - is held directly on
archive_attempt by test_archive_attempt_skips_absent_artifacts_and_reports_
what_it_copied below, which is where it can be exercised without depending
on which stage happens to run last.
"""
_, run_dir = retry_then_pass
assert not (run_dir / "attempts" / "attempt-1" / "documentation-report.md").exists()
assert "documentation-report.md" in story_coordinator.archivable_artifacts(
archive = run_dir / "attempts" / "attempt-1"
archived = story_coordinator.archivable_artifacts(
json.loads((REPO_ROOT / "workflows" / "story-workflow.json").read_text())["stages"]
)
assert (run_dir / "documentation-report.md").is_file()
for name in ("documentation-report.md", "documenter-changed-files.json"):
assert name in archived, name
assert (archive / name).is_file(), name
assert (run_dir / name).is_file(), name


def test_the_archive_happens_before_the_retry_begins(retry_then_pass):
Expand Down Expand Up @@ -276,8 +293,8 @@ def test_routing_is_unchanged_by_the_archive(retry_then_pass):
assert state["status"] == "completed"
assert state["retry_count"] == 1
assert runner.calls == [
"implementer", "tester", "verifier",
"implementer", "tester", "verifier", "documenter",
"implementer", "tester", "documenter",
"verifier", "implementer", "tester", "documenter", "verifier",
]
assert "retry 1 of 2" in (run_dir / "events.log").read_text()

Expand Down
4 changes: 2 additions & 2 deletions tests/test_branch_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -676,8 +676,8 @@ def test_the_note_does_not_change_where_execution_goes(stale, make_based):
git(fresh, "branch", STORY_BRANCH)
assert run(fresh)[0] == 0

assert stale_runner.calls == ["implementer", "tester", "verifier",
"documenter"]
assert stale_runner.calls == ["implementer", "tester", "documenter",
"verifier"]
assert notes(fresh) == []


Expand Down
44 changes: 34 additions & 10 deletions tests/test_changed_files_records.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ 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"]
assert runner.calls == ["implementer", "tester", "documenter", "verifier"]


# --------------------------------------------------------------------------
Expand All @@ -168,7 +168,13 @@ def test_enforcement_follows_declaration_not_stage_name(target_root, harness_roo
# --------------------------------------------------------------------------


ALL_STAGES = ["implementer", "tester", "verifier", "documenter"]
ALL_STAGES = ["implementer", "tester", "documenter", "verifier"]

#: Where a run ends when the documenter's own record is what escalates it.
#: Since story-045 the documenter runs before the verifier, so an escalation
#: at that stage leaves the stage after it uninvoked. Derived from the list
#: above rather than written out, so the two cannot disagree.
THROUGH_DOCUMENTER = ALL_STAGES[:ALL_STAGES.index("documenter") + 1]

#: 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.
Expand Down Expand Up @@ -291,21 +297,39 @@ def _sources_naming(directory: Path, name: str) -> list[str]:
if name in p.read_text(encoding="utf-8"))


#: The one module allowed to spell the documenter's record, and it is the
#: injection side rather than the enforcement side: since story-045 the
#: verifier is handed the documenter's record through a placeholder, and
#: context_assembler already spells the implementer's and the tester's records
#: the same way. The exemption is held shut from both directions below — the
#: exempt module must actually contain the name, or it is stale — and the
#: subject is unchanged: what the *coordinator* enforces still reaches it only
#: off the loaded workflow.
NAMES_THE_RECORD_FOR_INJECTION = "context_assembler.py"


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.
The absence is that no module under orchestration/ spells it, save the one
exempt module named above; 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") == []
naming = _sources_naming(orchestration, "documenter-changed-files.json")
assert naming == [NAMES_THE_RECORD_FOR_INJECTION]
# Held shut from the other side: the coordinator, which is what enforces
# the record, still spells neither the record nor the stage's own name for
# it, so the exemption cannot quietly widen into the routing code.
assert "documenter-changed-files.json" not in (
orchestration / "story_coordinator.py").read_text(encoding="utf-8")

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"]
assert _sources_naming(planted, "documenter-changed-files.json") == [
NAMES_THE_RECORD_FOR_INJECTION, "planted.py"]


def test_documenter_writing_a_clean_record_completes_the_run(target_root, harness_root):
Expand All @@ -329,7 +353,7 @@ def test_documenter_without_a_record_escalates_as_a_missing_artifact(
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
assert runner.calls == THROUGH_DOCUMENTER
reason = story_coordinator.escalation_reason(runner.run_dir)
assert reason == ("documenter did not produce required artifacts: "
"documenter-changed-files.json")
Expand All @@ -340,7 +364,7 @@ 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
assert runner.calls == THROUGH_DOCUMENTER
reason = story_coordinator.escalation_reason(runner.run_dir)
assert reason == "documenter modified blocked path: rules/execution-rules.json"

Expand Down Expand Up @@ -376,7 +400,7 @@ def test_documenter_record_failing_the_schema_escalates_as_invalid(
)
code = story_coordinator.run_story("story-001", harness_root, target_root, runner)
assert code == 2
assert runner.calls == ALL_STAGES
assert runner.calls == THROUGH_DOCUMENTER
reason = story_coordinator.escalation_reason(runner.run_dir)
assert reason.startswith(
"documenter wrote an invalid artifact: documenter-changed-files.json "
Expand Down
44 changes: 29 additions & 15 deletions tests/test_clean_clone_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -795,19 +795,27 @@ def committed_failure_run(story_target, harness_root):
return code, runner, run_dir_of(story_target)


def test_a_story_that_fails_only_once_committed_never_reaches_the_documenter(
def test_a_story_that_fails_only_once_committed_never_completes(
committed_failure_run,
):
"""What the check buys, restated where story-045 moved it.

It used to be that such a run never reached the documenter, the last
stage. The documenter now runs before the verifier, so the guarantee is
the one that was always the point: a story whose suite fails where the
code ships does not finish - no completion report, and the run ends
escalated with its retries spent.
"""
code, runner, run_dir = committed_failure_run
assert code == 2
assert "documenter" not in runner.calls
assert not (run_dir / "documentation-report.md").exists()
assert not (run_dir / "completion-report.md").exists()
assert read_state(run_dir)["status"] == "escalated"


def test_the_same_story_with_its_baseline_corrected_advances(green_run):
code, runner, run_dir = green_run
assert code == 0
assert runner.calls == ["implementer", "tester", "verifier", "documenter"]
assert runner.calls == ["implementer", "tester", "documenter", "verifier"]
assert read_state(run_dir)["status"] == "completed"


Expand All @@ -834,16 +842,22 @@ def test_a_failing_check_records_its_evidence_too(committed_failure_run):
assert schema_validator.validate(record, SCHEMA) == []


def test_the_check_runs_before_the_documenter_stage_starts(green_run):
"""Ordering asserted on the event stream, not on the call list alone."""
def test_the_check_runs_after_the_documenter_stage_completes(green_run):
"""Ordering asserted on the event stream, not on the call list alone.

story-045 moved the documenter ahead of the verifier, so the check - which
runs on the verifier's passing verdict - now clones a tree that already
holds the documenter's edits. The ordering is what says so.
"""
_, _, run_dir = green_run
events = [e["event"] for e in history_of(run_dir)]
stages = [e.get("stage") for e in history_of(run_dir)]
passed = events.index("clean-clone-passed")
documenter = next(
index for index, entry in enumerate(history_of(run_dir))
if entry["event"] == "stage-started" and entry["stage"] == "documenter")
assert events.index("verification-passed") < passed < documenter
if entry["event"] == "stage-completed" and entry["stage"] == "documenter")
assert documenter < events.index("verification-passed") < passed
assert passed < events.index("story-completed")
assert stages[passed] == "verifier"


Expand All @@ -870,11 +884,11 @@ def test_a_clean_clone_failure_reroutes_to_the_workflows_declared_retry_stage(
_, runner, _ = committed_failure_run
retry_stage = VERIFIER_STAGE["clean_clone"]["retry_stage"]
assert runner.calls == [
"implementer", "tester", "verifier",
"implementer", "tester", "verifier",
"implementer", "tester", "verifier",
"implementer", "tester", "documenter", "verifier",
"implementer", "tester", "documenter", "verifier",
"implementer", "tester", "documenter", "verifier",
]
assert runner.calls[3] == retry_stage
assert runner.calls[4] == retry_stage


def test_each_clean_clone_failure_increments_the_retry_count_exactly_once(
Expand Down Expand Up @@ -935,7 +949,7 @@ def test_a_refused_check_escalates_naming_the_missing_interpreter(
entry = history_of(run_dir)[-1]
assert entry["event"] == "escalated"
assert ".venv999/bin/python" in entry["message"]
assert "documenter" not in runner.calls
assert not (run_dir / "completion-report.md").exists()
assert record_of(run_dir)["ran"] is False


Expand Down Expand Up @@ -975,8 +989,8 @@ def test_a_failed_verification_still_retries_exactly_as_before(

run_dir = run_dir_of(story_target)
assert runner.calls == [
"implementer", "tester", "verifier",
"implementer", "tester", "verifier", "documenter",
"implementer", "tester", "documenter",
"verifier", "implementer", "tester", "documenter", "verifier",
]
assert read_state(run_dir)["retry_count"] == 1
entry = next(e for e in history_of(run_dir) if e["event"] == "verification-failed")
Expand Down
2 changes: 1 addition & 1 deletion tests/test_config_keys_are_obeyed.py
Original file line number Diff line number Diff line change
Expand Up @@ -932,7 +932,7 @@ def test_workflow_names_the_definition_the_run_actually_executes(tmp_path):
# distinguishes "the named definition was loaded" from "a definition with
# the same stages as the shipped one was loaded".
assert AUDIT_STAGE in run.stages
assert run.stages == ["implementer", "tester", "verifier", "documenter",
assert run.stages == ["implementer", "tester", "documenter", "verifier",
AUDIT_STAGE]
assert (run.run_dir / AUDIT_ARTIFACT).is_file()
assert AUDIT_STAGE not in [
Expand Down
12 changes: 10 additions & 2 deletions tests/test_contract_assertions_bite.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,9 +598,17 @@ def test_the_functions_that_did_change_are_only_those_that_took_the_baseline():


def test_the_named_survivors_are_present_and_unchanged():
"""The three the acceptance criteria name outright."""
"""The three the acceptance criteria name outright.

Bounded at *this story's* endpoint, like both siblings above and for the
same reason: read against today's working tree it asks what the file looks
like now, so a later story that legitimately edits one of the three turns
it red for something story-011 has nothing to say about — which is what
story-045 did to it by reordering the stage list those assertions name.
The subject and the strictness are unchanged; only the upper bound moves.
"""
before = functions_of(story_011_before_this_story())
after = functions_of(STORY_011_FILE.read_text(encoding="utf-8"))
after = functions_of(story_011_at_this_storys_endpoint())
for name in ("test_every_log_line_has_one_history_entry_in_the_same_order",
"test_the_retried_run_records_both_attempts_in_one_stream",
"test_the_history_a_run_produced_validates_against_the_schema"):
Expand Down
Loading
Loading