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
46 changes: 36 additions & 10 deletions .harness/docs/ARCHITECTURE.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions .harness/history/execution-log.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,4 @@
{"story_id": "story-153", "status": "escalated", "retry_count": 1, "timestamp": "2026-09-16 12:27:50"}
{"story_id": "story-153", "status": "resumed", "retry_count": 1, "timestamp": "2026-09-16 12:32:22"}
{"story_id": "story-153", "status": "completed", "retry_count": 1, "timestamp": "2026-09-16 12:55:13"}
{"story_id": "story-154", "status": "completed", "retry_count": 1, "timestamp": "2026-09-16 17:32:45"}
4 changes: 4 additions & 0 deletions .harness/history/inspection-log.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,7 @@
{"story_id": "story-153", "timestamp": "2026-09-16 12:27:41", "filed": 1, "dropped": 0, "routed": 3, "acted_on": 2}
{"story_id": "story-153", "mode": "narrow", "cost_usd": 2.5583735, "scope_files": 60, "invocations": 1, "timestamp": "2026-09-16 12:46:28", "findings": 1, "filed": 0, "dropped": 0, "dedupe_ran": true}
{"story_id": "story-153", "timestamp": "2026-09-16 12:49:39", "filed": 1, "dropped": 0, "routed": 1, "acted_on": 0}
{"story_id": "story-154", "mode": "narrow", "cost_usd": 3.7946285, "scope_files": 60, "invocations": 1, "timestamp": "2026-09-16 16:22:57", "findings": 4, "filed": 0, "dropped": 0, "dedupe_ran": true}
{"story_id": "story-154", "timestamp": "2026-09-16 17:01:19", "filed": 4, "dropped": 0, "routed": 4, "acted_on": 0}
{"story_id": "story-154", "mode": "narrow", "cost_usd": 3.3866405000000004, "scope_files": 60, "invocations": 1, "timestamp": "2026-09-16 17:18:31", "findings": 6, "filed": 0, "dropped": 0, "dedupe_ran": true}
{"story_id": "story-154", "timestamp": "2026-09-16 17:24:21", "filed": 5, "dropped": 0, "routed": 6, "acted_on": 1}
1 change: 1 addition & 0 deletions .harness/history/mandate-log.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,4 @@
{"story_id": "story-151", "timestamp": "2026-09-15 14:59:07", "conferred_by": "jerod.wilkerson <30474318+jerodw@users.noreply.github.com>", "source_kind": "human", "recorded_by": "l5-plan", "discarded_session_block": false}
{"story_id": "story-152", "timestamp": "2026-09-16 09:53:13", "conferred_by": "jerod.wilkerson <30474318+jerodw@users.noreply.github.com>", "source_kind": "human", "recorded_by": "l5-plan", "discarded_session_block": false}
{"story_id": "story-153", "timestamp": "2026-09-16 12:03:52", "conferred_by": "jerod.wilkerson <30474318+jerodw@users.noreply.github.com>", "source_kind": "human", "recorded_by": "l5-plan", "discarded_session_block": false}
{"story_id": "story-154", "timestamp": "2026-09-16 14:56:07", "conferred_by": "jerod.wilkerson <30474318+jerodw@users.noreply.github.com>", "source_kind": "human", "recorded_by": "l5-plan", "discarded_session_block": false}
1 change: 1 addition & 0 deletions .harness/history/retry-log.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,4 @@
{"story_id": "story-152", "timestamp": "2026-09-16 10:28:50", "stage": "verifier", "retry_decision": "retry", "retry_category": "validation", "retry_stage": "tester"}
{"story_id": "story-153", "timestamp": "2026-09-16 12:27:41", "stage": "verifier", "retry_decision": "retry", "retry_category": "validation", "retry_stage": "tester"}
{"story_id": "story-153", "timestamp": "2026-09-16 12:27:49", "stage": "tester", "retry_stage": "tester"}
{"story_id": "story-154", "timestamp": "2026-09-16 17:01:19", "stage": "verifier", "retry_decision": "retry", "retry_category": "validation", "retry_stage": "tester"}
202 changes: 202 additions & 0 deletions .harness/stories/story-154.yaml

Large diffs are not rendered by default.

36 changes: 26 additions & 10 deletions orchestration/harness_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,9 +263,17 @@ def workflow_token_values(config: dict) -> dict[str, str | None]:
the definition happens to carry, so the keys this resolution reads are
visible to a reader -- and to the scan that holds the declared set equal
to the set the harness reads -- exactly like every other configured key.
That the mapping has one entry is the narrowness, stated in code.
That the mapping has two entries, each named as a literal read, is the
narrowness: a declared key outside this set -- `branch_prefix` among them
-- stays unreferable, so the set is narrow rather than general. `tests_dir`
resolves to a single string; `architecture_docs` resolves to a list, whose
entries `_resolve_tokens` splices into the declaration in place of the
token entry.
"""
return {"tests_dir": config.get("tests_dir")}
return {
"tests_dir": config.get("tests_dir"),
"architecture_docs": config.get("architecture_docs"),
}


def workflow_names(harness_root: Path) -> tuple[str, ...]:
Expand Down Expand Up @@ -330,12 +338,16 @@ def __init__(self, workflow: str, tokens: list[str]):
def _resolve_tokens(value, values: dict[str, str | None], unresolved: list[str]):
"""Substitute every `{{key}}` list entry, dropping the ones with no value.

An unset key resolves the entry *out of the list* rather than to an empty
string: a restriction whose prefix is "" is a prefix every path is under,
which is the opposite of the "this target declares none" the absence
means. Every other token-shaped string -- one naming a key outside the
narrow set, or a resolvable one somewhere a list entry cannot be dropped
from -- is collected as unresolved for the caller to refuse on.
A token whose configured value is a list splices that list's entries into
the declaration in place of the token entry; a token whose value is a
single string replaces the entry with that string. An unset or empty value
resolves the entry *out of the list* rather than to an empty string: a
restriction whose prefix is "" is a prefix every path is under, which is the
opposite of the "this target declares none" the absence means, and an empty
list splices in nothing for the same reason. Every other token-shaped string
-- one naming a key outside the narrow set, or a resolvable one somewhere a
list entry cannot be dropped from -- is collected as unresolved for the
caller to refuse on.
"""
if isinstance(value, dict):
return {key: _resolve_tokens(item, values, unresolved)
Expand All @@ -350,8 +362,12 @@ def _resolve_tokens(value, values: dict[str, str | None], unresolved: list[str])
name = match.group(1)
if name not in values:
unresolved.append(name)
elif values[name]:
resolved.append(values[name])
continue
configured = values[name]
if isinstance(configured, list):
resolved.extend(configured)
elif configured:
resolved.append(configured)
return resolved
if isinstance(value, str):
unresolved.extend(_WORKFLOW_TOKEN.findall(value))
Expand Down
86 changes: 85 additions & 1 deletion orchestration/story_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,19 @@ def resolves_nothing(source: dict) -> dict | None:
CONFINEMENT = "may_only_change"


def _inside(prefix: str, path: str) -> bool:
"""Whether `path` is at or beneath a confinement prefix.

A prefix ending in a slash is a directory and covers everything beneath it;
any other prefix is exactly one file and covers only the identical path.
The same slash convention `grant_covers` gives a granted value, so a
confinement and a grant agree about what a slash-less value means.
"""
if prefix.endswith("/"):
return path.startswith(prefix)
return path == prefix


@dataclass(frozen=True)
class StageRestriction:
"""One declared restriction on where a stage may write, and what it means.
Expand Down Expand Up @@ -435,10 +448,20 @@ def governs(self, path: str) -> bool:
restriction claim a path only its sibling covers. A confinement is the
opposite and has to read the group, because being confined to two
directories means being governed only outside *both*.

A prefix not ending in a slash is exactly that file, not a directory
whose name it happens to be: `.harness/docs/ARCHITECTURE.md` confines a
stage to that one file, so `.harness/docs/ARCHITECTURE.md.bak` is
governed rather than read as inside it. That is the meaning `grant_covers`
already gives a slash-less value and the meaning the `:(exclude,top)`
pathspec the baseline capture builds already has; a prefix ending in a
slash keeps its directory meaning. A confinement decides `inside` per
prefix rather than by `startswith`, so a slash-less prefix admits only
the identical path.
"""
if self.sense == CREATE_RESTRICTION:
return path.startswith(self.prefix)
return not any(path.startswith(prefix) for prefix in self.prefixes)
return not any(_inside(prefix, path) for prefix in self.prefixes)

@property
def wording(self) -> str:
Expand Down Expand Up @@ -5933,6 +5956,48 @@ def correction_pass_result_file(artifact: str, number: int) -> str:
return f"{stem}-{number}{dot}{extension}"


def correction_pass_grants(
run_dir: Path, stages: list[dict], state: RunState, stage_name: str, attempt: int
) -> list[str]:
"""The files this pass's findings grant one stage to change for one attempt.

A correctable finding names a `path`, and naming it is the grant: at the
stage the pass enters, the exempt list the ownership and revert checks read
gains that path, so a finding grants its stage the one file it names for
this pass. The record is read off the run directory rather than passed in
memory, exactly as the prompt injection of the same record is, so what the
checks exempt and what the prompt reports are one thing.

The grant is bounded to the stage and the attempt the record names: a
documenter re-entered by a later retry in the same run runs at a later
attempt, so the attempt no longer matches and the grant is not inherited.
A record for another stage grants nothing here either. A run that has taken
no pass, a workflow that declares none, and a record that is absent or
unreadable each grant nothing.
"""
if not state.correction_pass_count:
return []
declared = correction_pass_declaration(stages).get("result")
if not declared:
return []
record = run_dir / correction_pass_result_file(
declared, state.correction_pass_count
)
if not record.is_file():
return []
try:
parsed = json.loads(record.read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
if parsed.get("stage") != stage_name or parsed.get("attempt") != attempt:
return []
return [
finding["path"]
for finding in parsed.get("findings", [])
if finding.get("path")
]


def correction_pass_statement(stage_name: str) -> str:
"""What the coordinator tells the stage a correction pass routes to.

Expand Down Expand Up @@ -9013,6 +9078,25 @@ def elapsed() -> float | None:
kind="stage-exception-applied",
stage=name,
)
# A correction pass grants the stage it enters the files its
# findings name, for that pass alone. The verifier's verdict copied
# verbatim into the pass record is the grant, so what was granted is
# recorded by the act that granted it; the exempt list here is the
# story's own grants plus the path of every finding in the current
# pass record, when that record names this stage and this attempt.
# Each granted path is an event beside `stage exception applied` so
# the two read alike in events.log.
pass_grants = correction_pass_grants(
run_dir, stages, state, name, attempt
)
for granted in pass_grants:
append_event(
run_dir,
f"correction-pass grant applied: {name} may change {granted}",
kind="correction-pass-grant-applied",
stage=name,
)
exempt = exempt + pass_grants
ownership = _ownership_violation(run_dir, record_name, enforced, exempt)
if ownership:
return _escalate(
Expand Down
11 changes: 8 additions & 3 deletions prompts/documenter.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,14 @@ and fixable in the words alone. The coordinator wrote it, not an agent. Correct
the words each finding names and nothing else: this changes prose and never
behaviour, so nothing you change for it may alter what any test asserts. You
are not asked to establish that — the clean-clone check the coordinator runs
after the pass confirms the correction changed no behaviour. The approved story
artifact is never edited by a pass; if a finding names it, correct nothing and
say so in your report. No retry was spent and the verdict still stands:
after the pass confirms the correction changed no behaviour. The files the
findings name are yours to change for this pass, and the architecture documents
always are. An edit to any other file is put to the revert check after your
turn ends, and undone if the suite is green without it — so if a correction
cannot land in a file you may change, report that in documentation-report.md
rather than attempting it elsewhere. The approved story artifact is never edited
by a pass; if a finding names it, correct nothing and say so in your report. No
retry was spent and the verdict still stands:
{{correction_pass_result}}

Retry guidance:
Expand Down
3 changes: 2 additions & 1 deletion prompts/planner.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ Do not:
- write the story artifact before the developer approves the plan.

[Process]
1. Read .harness/docs/ARCHITECTURE.md and the standards in .harness/standards/.
1. Read the architecture documents the target's configuration names under
`architecture_docs`, and the standards in .harness/standards/.
2. Ask the developer any questions required to remove important
ambiguity. Ask about decisions that are expensive or hard to reverse;
apply sensible defaults to cheap, reversible ones and say you did.
Expand Down
10 changes: 8 additions & 2 deletions prompts/refactor-verifier.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ can fix mechanically, and whose repair is to the words alone — a comment, a
docstring, a schema description, a document. Name the retry category that owns
that prose, spelled exactly as the categories above are: a category this
workflow does not define escalates the run rather than being routed somewhere.
Give each finding a `path`, the repository-relative file the words to correct
are in. Naming it is what lets the stage the pass enters at change that one
file for that pass: the coordinator grants that stage the files the findings
name, and an edit to any other file is put to the revert check after the turn.
A finding that names no path is refused by the schema, because the pass would
have no scope to change the words in.

What stays in unverified is what you could not check: evidence that was
missing, a claim you had no way to settle, a question the artifacts available
Expand Down Expand Up @@ -246,8 +252,8 @@ story scope, and note that edits to existing tests are within this stage's
work under this workflow):
{{changed_files}}

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

Documentation report (the documenter's account of what it wrote and why):
Expand Down
10 changes: 8 additions & 2 deletions prompts/story-verifier.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ can fix mechanically, and whose repair is to the words alone — a comment, a
docstring, a schema description, a document. Name the retry category that owns
that prose, spelled exactly as the categories above are: a category this
workflow does not define escalates the run rather than being routed somewhere.
Give each finding a `path`, the repository-relative file the words to correct
are in. Naming it is what lets the stage the pass enters at change that one
file for that pass: the coordinator grants that stage the files the findings
name, and an edit to any other file is put to the revert check after the turn.
A finding that names no path is refused by the schema, because the pass would
have no scope to change the words in.

What stays in unverified is what you could not check: evidence that was
missing, a claim you had no way to settle, a question the artifacts available
Expand Down Expand Up @@ -219,8 +225,8 @@ 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 (documenter's record — every repository file the
documenter stage touched):
{{documenter_changed_files}}

Documentation report (the documenter's account of what it wrote and why):
Expand Down
6 changes: 5 additions & 1 deletion schemas/correction-pass.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@
"description": "The correctable findings the verdict carried, copied verbatim. The item shape is declared inline rather than referenced, because this validator subset has no cross-file reference and a finding recorded here must validate identically to one in verification-result.schema.json's correctable_findings — the duplication is stated here so the two are edited together rather than drifting.",
"items": {
"type": "object",
"required": ["location", "finding", "correction", "category"],
"required": ["path", "location", "finding", "correction", "category"],
"properties": {
"path": {
"type": "string",
"description": "The repository-relative file the words to correct are in, as the verifier recorded it. Naming it is what lets the stage this pass enters at change that file: the coordinator adds the path of every finding in this record to that stage's exempt list, so the finding grants its stage the one file it names for this pass, while an edit to any file no finding named stays governed by the revert check."
},
"location": {
"type": "string",
"description": "Where the wording is, as the verifier recorded it."
Expand Down
1 change: 1 addition & 0 deletions schemas/execution-history.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"claim-support-checked",
"correction-pass-routed",
"correction-pass-recorded",
"correction-pass-grant-applied",
"self-routed",
"budget-stopped",
"capacity-paused",
Expand Down
6 changes: 5 additions & 1 deletion schemas/verification-result.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,12 @@
"description": "Findings you judge correct but too small to fail the run, that one stage can fix mechanically. Its presence is the signal that a correction pass should run: there is no separate key saying whether this field means anything, for the same limitation retry_target, unfinishable_by_retry and guidance_outcomes.unmet already record above — this validator subset has no if/then and no dependentRequired, so a flag with a sibling payload has a state where the flag is set and the payload is missing, while presence-as-signal has none. A correctable finding is a correction to words alone — a comment, a docstring, a schema description, a document — and never to behaviour: nothing a correction pass changes may alter what any test asserts about the system, and the suite must pass unchanged across a pass. A finding whose repair would change behaviour is a blocking issue or belongs in unverified, not here. It is read only on a passing verdict, because a failed verdict already routes and the correction travels in the retry guidance it already writes. It is optional because almost every verdict has no such finding.",
"items": {
"type": "object",
"required": ["location", "finding", "correction", "category"],
"required": ["path", "location", "finding", "correction", "category"],
"properties": {
"path": {
"type": "string",
"description": "The repository-relative file the words to correct are in. Naming it is what lets the stage the pass enters at change that file: at the documenter's post-stage checks the exempt list is the story's own grants plus the path of every finding in the current pass record, so a finding grants its stage the one file it names, for that pass alone. An edit to any file no finding named stays governed by the revert check. This is why a correctable finding without a path fails schema validation — the pass would run with no scope to change the words in."
},
"location": {
"type": "string",
"description": "Where the wording is: the file, and the symbol, section or line within it that carries the words to correct."
Expand Down
Loading
Loading