04 · The harness tree
04 · The harness tree
Summary. In Harness², a "harness" is a small tree of text files around a fixed agent framework (OpenCode or codex-cli). An improvement step may change only these files, and the eight component slots c1..c8 decide which files count. One module, harness2/core/tree.py, reads, validates, writes, diffs and compares these trees. Everything that differs between the two frameworks is a data value, SurfaceSpec, so the shared logic exists only once. An edit is "what changed on disk": files the editor does not touch are inherited. Each proposed tree is repaired (missing skill triggers are added automatically), then validated against path, cap, frontmatter, script and hook rules, and gets one retry with the error text before the stage falls back to the harness it started from. This chapter goes through every symbol in tree.py, all eight components on both frameworks with the real baseline files, and the life of a single edit from proposal to adoption.
All code links point to commit 1cfe254e8d25b15f37048c3730134686abe60335.
1. Where the tree sits in the system
tree.py is the bottom layer that every other module goes through when it reads or writes a harness:
| Caller | What it uses from tree.py | Why |
| schedule.run_task | read_tree(surface, adapter.base_harness()) | Loads the unmodified starting harness (the "base tree") for a task |
| recursion._run_stage | read_tree(..., rejected=...), write_tree | Reads the editor's edited workspace back, and writes a rejected tree back for the retry |
| recursion._accept_tree | autotrigger, diff_summary, validate, RESERVED_SCRIPTS | Repairs, then accepts or rejects one proposal |
| recursion._roll | write_tree | Writes a candidate to work_dir/harnesses/<label> before running it |
| recursion.run_parallel | diversity | Measures how similar the k final compositions are |
| context._write_pool | read_tree | Builds a harness.diff between the current harness and each pooled candidate |
| library.Library / _inventory | read_tree, write_tree(stubs=False), always_on_text | Stores adopted harnesses per domain and summarises them |
| schedule._tree_is_base | diff_summary | Records whether the chosen harness equals the base |
| OpencodeSubstrate.materialise / CodexSubstrate.materialise | is_tree, normalize_rel | Refuse to install a directory that is not a harness tree |
| Benchmark adapters (JobBench, WorkBuddy, LAB) | is_tree, write_tree, always_on_text | Build or check the base harness, and add benchmark-specific checks |
Each framework (a "substrate", see chapter 07) hands the core its surface through one method, surface(): see SubstrateLike.surface, OpencodeSubstrate.surface and CodexSubstrate.surface. The core never branches on the framework name. It only reads fields of the SurfaceSpec it was given.
flowchart LR
BASE["adapter.base_harness() dir"] -->|"read_tree"| BT["base_tree (dict rel to text)"]
BT -->|"write_tree"| HD["work_dir/harnesses/base"]
HD -->|"context.build copies"| CH["context/harness/ (editor edits in place)"]
CH -->|"Proposer harvest"| WS["harvested workspace"]
WS -->|"read_tree + rejected"| NEW["new tree"]
NEW -->|"autotrigger, validate"| OK{"valid?"}
OK -->|"yes"| ROLL["write_tree to harnesses/label, then rollout"]
OK -->|"no, attempt 1"| CH
OK -->|"no, attempt 2"| FB["fall back to inherited tree"]
click BT href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/schedule.py#L617" "schedule.run_task loads base tree"
click HD href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/recursion.py#L366" "run_parallel writes base"
click CH href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/context.py#L153-L156" "context.build copies harness"
click WS href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/proposer.py#L313-L325" "Proposer._harvest"
click NEW href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/recursion.py#L211-L224" "read_tree with rejected"
click OK href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/recursion.py#L108-L132" "_accept_tree"
click ROLL href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/recursion.py#L260-L300" "_roll"
click FB href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/recursion.py#L242-L251" "fallback stage"2. Every symbol in tree.py
2.1 Module-level names
| Name | Line | Value / signature | What it is for |
| Tree | 31 | dict[str, str] | A harness in memory: relative POSIX path → file text. Everything else in the module takes or returns one. |
| RESERVED_SCRIPTS | 34 | frozenset({"read_doc.py", "pack.py", "unpack.py"}) | Script basenames that the runtime already puts in the workspace. A skill may invoke them without shipping them. Benchmarks can add more through BenchSpec.reserved_scripts (LAB on OpenCode does this, lab.py L492). |
| _NAME | 37 | r"[a-z0-9][a-z0-9_\-]*" | The legal component name for skills, subagents and plugins: lowercase letters, digits, _ and -, not starting with _ or -. |
Tree = dict[str, str]
RESERVED_SCRIPTS = frozenset({"read_doc.py", "pack.py", "unpack.py"})
_NAME = r"[a-z0-9][a-z0-9_\-]*"2.2 SurfaceSpec: the editable surface as data
SurfaceSpec is a frozen dataclass: "The editable surface of one agent framework: paths, caps and per-surface rules." There are exactly two instances in the repo: OPENCODE_SURFACE and CODEX_SURFACE.
| Field | Type | Meaning | OpenCode value | Codex value | Read by |
name | str | Surface id. Also the key that prompts._st uses to pick the matching "WHAT YOU CAN CHANGE" text | "opencode" | "codex" | _rx, prompts |
always_on | tuple[str, ...] | Files that are in the agent's context on every run. Order matters for always_on_text | systemprompt.md, guardrails.md, LongTermMEMORY.md, tool_descriptions/tool_guidance.md | AGENTS.md | is_allowed, is_tree, write_tree, always_on_text, validate |
always_on_max | int | Character cap on the joined always-on text | 24_000 | 8_000 | validate, autotrigger |
always_on_cap_reason | str | Explanation appended to the cap error, so the editor learns why | "every token here competes with the agent's own output budget" | "AGENTS.md is injected whole into EVERY turn, and codex additionally truncates it silently at 32,768 bytes" | validate |
skill_dir | str | Root folder of skills | skills | .agents/skills | regexes, autotrigger, _check_skills |
skill_name_rule | str | How the frontmatter name: relates to the directory | "must_match" (required and equal) | "overrides_dir" (optional; if present it must equal the directory) | _check_skills |
trigger_hint | str (format with {name}) | Fix-it hint appended to the "untriggered skill" error | "…add a trigger line naming {name} to systemprompt.md or guardrails.md…" | "…add a trigger line naming {name} to AGENTS.md…" | _check_skills |
autotrigger_file | str | The always-on file the automatic repair appends to | guardrails.md | AGENTS.md | autotrigger |
trigger_line | str (format with {name}, {desc}) | Template of the repaired line | "- When {desc}, load the {name} skill.\n" | "- When {desc}, read .agents/skills/{name}/SKILL.md and follow it.\n" | autotrigger |
subagent_requires_mode | bool | Whether AGENT.md must carry mode: subagent | True | False | _check_subagents |
plugin_dir | str | None | Folder of TypeScript plugins (c8 on OpenCode) | plugin | None | _res, is_allowed, validate census |
hooks_file | str | None | The single hooks JSON file (c8 on Codex) | None | .codex/hooks.json | is_allowed, validate, check_hooks |
hook_events | frozenset[str] | Event names the framework dispatches | empty | PreToolUse, PostToolUse, PermissionRequest, PreCompact, PostCompact, SessionStart, SubagentStart, SubagentStop, UserPromptSubmit, Stop | check_hooks |
compile_skill_scripts | bool | Whether .py files under a skill's scripts/ must compile too | False | True | validate |
reject_empty_always_on | bool | Whether an empty always-on body is an error | False | True | validate |
stubs | tuple[tuple[str, str], ...] | README placeholder files (path, body) written into each tree so an editor sees where new components go. They are not part of the tree | 4 stubs: scripts/, sub_agents/, plugin/, skills/ | 4 stubs: scripts/, sub_agents/, .codex/, .agents/skills/ | _stub_paths, read_tree, write_tree |
The class itself is short:
@dataclass(frozen=True)
class SurfaceSpec:
"""The editable surface of one agent framework: paths, caps and per-surface rules."""
name: str
always_on: tuple[str, ...]
always_on_max: int
always_on_cap_reason: str
skill_dir: str
skill_name_rule: str
trigger_hint: str
autotrigger_file: str
trigger_line: str
subagent_requires_mode: bool
plugin_dir: str | None
hooks_file: str | None
hook_events: frozenset[str] = frozenset()
compile_skill_scripts: bool = False
reject_empty_always_on: bool = False
stubs: tuple[tuple[str, str], ...] = ()Why a dataclass and not subclasses? The module docstring says it directly: "Everything that differs between the two agent frameworks is data on SurfaceSpec* … plus a per-surface hooks-schema validator; the shared logic exists once"* (L15-L21). CodexSubstrate's docstring repeats this: "Per-deployment differences are values in Install.extras, never subclasses" (codex.py L15-L21). One consequence: a third framework would need one new SurfaceSpec value and one new prompt block in prompts._SURFACE_TEXTS, with no change to the validator.
2.3 Path regexes: _res and _rx
_res(surface_name, skill_dir, plugin_dir) builds the compiled regexes for one surface and caches them (@lru_cache(maxsize=8)). _rx(surface) is the convenience wrapper that every function calls. It passes only hashable fields, so the frozen dataclass never has to be hashed as a whole.
sd = re.escape(skill_dir)
out = {
"skill": re.compile(rf"{sd}/({_NAME})/SKILL\.md"),
"skill_script": re.compile(rf"{sd}/{_NAME}/scripts/[A-Za-z0-9_.\-]+"),
"subagent": re.compile(rf"sub_agents/({_NAME})/AGENT\.md"),
"script": re.compile(r"scripts/[a-z0-9_][a-z0-9_]*\.py"),
"script_ref": re.compile(rf"(?:python3?\s+)((?:{sd}/[\w\-]+/)?scripts/[\w.\-]+\.py)"),
}
if plugin_dir is not None:
out["plugin"] = re.compile(rf"{re.escape(plugin_dir)}/({_NAME})\.ts")
return out| Key | Matches (with fullmatch) | OpenCode example | Codex example | Notes |
skill | <skill_dir>/<name>/SKILL.md, capturing <name> | skills/doc_review/SKILL.md | .agents/skills/doc_review/SKILL.md | SKILL.md is case-sensitive |
skill_script | any file directly under <skill_dir>/<name>/scripts/ | skills/doc_review/scripts/check.py | .agents/skills/doc_review/scripts/run.sh | The filename may contain uppercase letters, . and -, and any extension. One folder deep only |
subagent | sub_agents/<name>/AGENT.md | same on both | same on both | Same path on both frameworks |
script | scripts/<snake>.py | scripts/check_totals.py | same | No hyphens, no uppercase: scripts/check-totals.py is off the surface |
script_ref | searched (not fullmatched) in text: python or python3, whitespace, then a script path | python3 scripts/x.py, python skills/s/scripts/y.py | python3 .agents/skills/s/scripts/y.py | Used by _check_script_refs to find scripts a text invokes |
plugin | <plugin_dir>/<name>.ts (OpenCode only) | plugin/verify_write.ts | — | Missing on Codex because plugin_dir is None |
2.4 Path functions
| Function | Signature | Returns | Behaviour and why |
| normalize_rel | (rel: str) -> str | cleaned path | Turns \ into /, raises ValueError("path escapes the harness") for absolute paths or any .. segment, removes leading ./ and leading/trailing /. It runs before the path is joined to a directory, so a proposal can never write outside the tree. It is also used for the file:<rel> integration entries in OpencodeSubstrate.materialise. |
| is_allowed | (surface, rel) -> bool | on surface? | "A predicate: it never raises." It catches normalize_rel's error and returns False. True for always-on files, the hooks file, the skill / skill-script / subagent / script patterns, and plugins where they exist. |
| skill_names | (surface, tree) -> list[str] | sorted skill names | Names come from the skill regex group; a skill exists only if its SKILL.md does. |
| subagent_names | (surface, tree) -> list[str] | sorted subagent names | Same idea for sub_agents/<name>/AGENT.md. |
| _stub_paths | (surface) -> frozenset[str] | stub paths | Used to skip the README placeholders when reading and to refresh them when writing. |
def normalize_rel(rel: str) -> str:
"""Normalise a relative path, rejecting escapes before it is joined to a directory."""
r = str(rel).strip().replace("\\", "/")
if r.startswith("/") or ".." in r.split("/"):
raise ValueError(f"path escapes the harness: {rel!r}")
while r.startswith("./"):
r = r[2:]
return r.strip("/")Some concrete answers from is_allowed, worked out from the regexes above (both columns use the real surface values):
| Path | OpenCode | Codex | Why |
systemprompt.md | yes | no | always-on only on OpenCode |
AGENTS.md | no | yes | always-on only on Codex. On OpenCode the runtime produces AGENTS.md, and the TTS prompt says "Never write AGENTS.md* or* harness.yaml" (prompts.py L463-L464) |
skills/doc_review/SKILL.md | yes | no | Codex skills live in .agents/skills/ |
skills/Doc/SKILL.md | no | no | uppercase is not allowed by _NAME |
skills/doc_review/notes.md | no | no | only SKILL.md and scripts/* are allowed inside a skill |
skills/doc_review/check.py | no | no | a helper "loose in the skill directory is OFF THE EDITABLE SURFACE" (prompts.py L163-L167) |
scripts/check_totals.py | yes | yes | snake_case .py |
scripts/check-totals.py | no | no | the script regex has no - |
scripts/helpers/x.py | no | no | one level only |
plugin/verify.ts | yes | no | Codex has no plugin dir |
.codex/hooks.json | no | yes | the hooks file is on the Codex surface only |
harness.yaml | no | no | it is a manifest, not a component |
../evil.md | no | no | normalize_rel raises; is_allowed turns that into False |
2.5 Reading and writing trees
read_tree(surface, d, *, rejected=None) -> Tree walks the directory recursively in sorted order. It skips non-files and __pycache__, skips README stubs, and keeps only paths that pass is_allowed. Off-surface files are dropped silently, unless the caller passes a rejected list, which then collects them. Text is read as UTF-8 with errors="replace", so binary junk never crashes a read. A missing directory gives {}.
That one keyword decides two behaviours:
- When the base harness is loaded (schedule.py L617), no list is passed. The JobBench OpenCode baseline's
harness.yamlmanifest is therefore dropped silently, because it is documentation, not a component. - When an editor's proposal comes back (recursion.py L211-L224), a list is passed. A non-empty list is a rejection: "these files are off the editable surface and were not applied: … Component names must be lowercase letters, digits,
_* or*-".
is_tree(surface, d) -> bool: a directory is a harness tree if at least one always-on file exists in it. Both substrates refuse to install anything else (opencode.py L192-L195, codex.py L129-L130). JobBench checks it as well, because "an absent base would make every base rollout silently harness-free" (jobbench.py L950-L958).
write_tree(surface, tree, dest, *, stubs=True) -> Path produces an exact copy of the tree on disk:
for p in sorted(dest.rglob("*"), reverse=True):
if not p.is_file():
continue
rel = str(p.relative_to(dest))
if is_allowed(surface, rel) or rel in stub_set:
p.unlink()
for a in surface.always_on:
p = dest / a
p.parent.mkdir(parents=True, exist_ok=True)
if a not in tree and not p.exists():
p.write_text("", encoding="utf-8")
for rel, content in tree.items():
p = dest / normalize_rel(rel)
...
if stubs:
for rel, body in surface.stubs:
...
if not p.exists():
p.write_text(body, encoding="utf-8")- Delete every file at
destthat is on the surface or is a stub. Files off the surface are kept ("Files outside the editable surface are preserved; editable files already there are not"). This is why a removed skill really disappears whenwrite_treewrites into an existingcontext/harness/for a retry. - Make sure every always-on file exists, as an empty file if the tree lacks it. A tree from which the editor deleted
guardrails.mdis still a valid OpenCode tree on disk (andis_treestays true). - Write every tree entry, passing each path through
normalize_relagain (defence in depth). - Write the README stubs unless
stubs=False. The library stores adopted harnesses withstubs=False(library.py L128) so the stored copy holds only real components.
always_on_text(surface, tree) -> str joins the always-on files in surface.always_on order with a blank line ("\n\n"). A missing file counts as "". This string is what the cap is measured on and where trigger names are searched. Note that the separators count: on OpenCode, four empty files still give a 6-character body.
2.6 autotrigger: the automatic repair
autotrigger (surface, tree, *, always_on_max=None) -> list[str] changes tree** in place** and returns the names of the skills it repaired.
for name in skill_names(surface, tree):
if name in body:
continue
fm = tree.get(f"{surface.skill_dir}/{name}/SKILL.md", "")[:600]
m = re.search(r"^description:\s*(.+)$", fm, re.M)
desc = m.group(1).strip().rstrip(".") if m else f"the {name} workflow applies"
line = surface.trigger_line.format(name=name, desc=desc)
cur = tree.get(surface.autotrigger_file, "")
if len(always_on_text(surface, tree)) + len(line) + 2 > cap:
continue
tree[surface.autotrigger_file] = (cur.rstrip() + "\n" + line) if cur.strip() else line
body = always_on_text(surface, tree)
fixed.append(name)- Trigger test. A skill counts as "triggered" when its directory name appears anywhere in the always-on text. This is a plain substring test (
name in body), not a word match. - Description source. The first 600 characters of
SKILL.mdare searched for adescription:line. The value is stripped and its trailing full stop removed. If there is none, the fallback is"the <name> workflow applies". - Cap respected. If the new line (+2 for the separator) would push the body past the cap, that skill is skipped. The later
validatecall then reports it as untriggered, and the editor must make room. - Why it exists. The surface prompt calls a missing trigger "the most violated rule here by a wide margin" (prompts.py L151-L157). An untriggered skill is useless (the agent never opens it), and throwing away a whole proposal for a mechanical omission wastes an editor session. Repairing it costs one line in always-on text. _accept_tree runs this repair before validation and logs
auto-triggered skill(s): ….
2.7 check_hooks: the Codex hooks schema
check_hooks(surface, src, errs) validates .codex/hooks.json text. It appends to errs and never raises. The docstring explains why the shape is checked here and not at runtime: "a malformed one never fires rather than erroring, so the shape is enforced where the proposer sees it." If codex-cli ignored a broken hooks file silently, the editor would believe its hook was in force.
| Check | Error text (prefix .codex/hooks.json) | Line |
| JSON parses | … is not valid JSON: <exc> (stops) | L221-L225 |
top level is an object with a non-empty "hooks" object | … needs a top-level {"hooks": {"<Event>": [...]}} object (stops) | L226-L229 |
every event key is in surface.hook_events | …: unknown event 'x'; the framework dispatches <sorted list> (PascalCase) | L231-L236 |
| event maps to a list | …: <Event> must map to a LIST of matcher entries | L237-L239 |
each entry is an object with a hooks list | …: <Event>[i] needs a hooks list | L241-L243 |
each hook has "type": "command" | …: <Event>[i] hook type must be "command" — async/prompt/agent hooks are skipped as 'not supported yet' | L245-L249 |
command is non-blank | …: <Event>[i] has an empty command`` | L250-L251 |
The matcher field is not checked (it is optional in the schema the prompt describes). A valid hooks file, using the example the Codex stub itself gives (codex.py L87-L94):
{"hooks": {"PostToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "python3 scripts/check_output.py"}]}]}}Typical failures: {"hooks": {"postToolUse": [...]}} fails with unknown event 'postToolUse'; the framework dispatches PermissionRequest, PostCompact, PostToolUse, PreCompact, PreToolUse, SessionStart, Stop, SubagentStart, SubagentStop, UserPromptSubmit (PascalCase). {"hooks": {"PostToolUse": {"matcher": "Bash"}}} fails with PostToolUse must map to a LIST of matcher entries. Hooks only fire because every Codex launch passes --dangerously-bypass-hook-trust ("without this flag codex runs but silently skips .codex/hooks.json", codex_agent.py L180-L181; see also CodexSubstrate.exec_argv).
2.8 The private checkers
| Function | What it appends to errs | Why |
| _py_compiles(rel, src, errs) | <rel> does not compile: SyntaxError: <msg> (line N) (detail cut to 160 chars). Uses the built-in compile(src, rel, "exec"), so nothing is executed. | A script that does not even parse wastes a rollout. The TTS prompt states the rule as "must compile under py_compile" (prompts.py L462). |
| _check_skills(surface, tree, body, errs) | (a) missing description: in the first 600 chars: "…or the skill is dropped SILENTLY at load". (b) On must_match (OpenCode): name: missing or different from the directory. (c) On overrides_dir (Codex): a name: that differs, which "OVERRIDES the directory name at load, so every trigger line naming <dir>* stops matching". (d) Skill name not found in always-on text: *"an untriggered skill is dead weight" + trigger_hint. Returns the skill names. | Every rule mirrors a way the framework would silently ignore the skill at load time. Turning silent drops into loud errors is the theme of the whole validator. |
| _check_subagents(surface, tree, allow_subagents, errs) | (a) Any subagent while allow_subagents=False: "sub_agents are not available on this deployment". (b) OpenCode only: missing mode: subagent. (c) Missing description:. Returns the names. | The deployment decides whether delegation exists; see c5 below. |
| _check_script_refs(surface, tree, r, reserved_scripts, errs) | For every SKILL.md and every always-on file, each python[3] <path>.py reference must match a shipped scripts/*.py or skill-script path, unless its basename is reserved: " invokes <ref>* but that file is not in the harness"*. | A skill that calls a missing helper fails at runtime. The editor often copies a skill from the library without its script (prompts.py L320-L326). |
2.9 validate: the whole gate
validate(surface, tree, *, allow_subagents=True, always_on_max=None, reserved_scripts=RESERVED_SCRIPTS) -> dict collects every problem before it raises, so a single retry message can list all of them:
- Always-on length over the cap →
always-on content is N chars, over the CAP cap; <reason>(L350-L354). - If
reject_empty_always_on(Codex) and the body is blank →AGENTS.md is empty — it is the only always-on file on this framework, so an empty one is a harness that does not exist(L355-L359). _check_skills, then_check_subagents(L361-L362).- Compile every
scripts/*.py, plus skill scripts ending in.pywhencompile_skill_scriptsis set (L364-L370). check_hooksif the surface has a hooks file and the tree contains it (L372-L373)._check_script_refs(L375).- If anything failed:
raise ValueError(" | ".join(errs)). Otherwise return a census:
out = {
"always_on_chars": len(body),
"n_files": len(tree),
"skills": len(skills),
"sub_agents": len(subs),
"scripts": sum(1 for rel in tree if r["script"].fullmatch(rel)),
}
if surface.plugin_dir is not None:
out["plugins"] = sum(1 for rel in tree if r["plugin"].fullmatch(rel))
if surface.hooks_file is not None:
out["hooks"] = 1 if surface.hooks_file in tree else 0
return outThe always_on_max argument can override the surface cap, but no caller at this commit passes it. _accept_tree passes allow_subagents=adapter.spec.allow_subagents and reserved_scripts=RESERVED_SCRIPTS | adapter.spec.reserved_scripts.
validate is only the first of three checks in _accept_tree:
try:
harness_tree.validate(
surface,
new,
allow_subagents=adapter.spec.allow_subagents,
reserved_scripts=harness_tree.RESERVED_SCRIPTS | adapter.spec.reserved_scripts,
)
adapter.validate_tree(new)
context.assert_tree_has_no_grader_verdicts(new)
except ValueError as exc:
return kept, str(exc)- adapter.validate_tree: benchmark invariants, "the message is fed back verbatim as retry feedback." JobBench adds nothing (L960-L962). WorkBuddy on OpenCode writes the tree, composes the Jinja prompt and runs assert_jinja_safe, "so a brace in a skill's
description:* is a rejected proposal rather than a dead trial"* (L925-L932). LAB requires the always-on text (Codex) or the rendered prompt (OpenCode) to still mentionoutput/andread_doc.py(L845-L900). - context.assert_tree_has_no_grader_verdicts: a tripwire. A harness file whose name or text contains a grader-verdict marker is refused, because "files carrying these strings cannot be served as evidence to later tasks" (chapter 05).
2.10 diff_summary
diff_summary(surface, before, after) -> dict returns sorted added, removed and modified path lists plus always_on_char_delta (signed change in always-on length). It is a summary only: it does not produce a unified diff (that is context._write_pool, which writes pool/<label>/harness.diff with difflib). Two callers use it to answer "is this the same harness?": _accept_tree sets kept_base when all three lists are empty, and schedule._tree_is_base records chosen_tree_is_base (L544-L546).
2.11 diversity
diversity (trees: dict[str, Tree]) -> dict, "Pairwise line-Jaccard over candidate trees, so collapse to one body is visible." See section 6.
3. The eight components, c1..c8
3.1 The numbering
The c-numbers come from the baseline manifest harness_baseline_oc/harness.yaml and from the TTS prompt tables (prompts.py L422-L431, L473-L480). They do not map one-to-one onto files:
- c2 (context policy) is not editable. "c2 context_policy: NOT injectable — OpenCode owns context assembly/compaction." (harness.yaml L18). No
SurfaceSpecpath maps to c2 on either framework. - c6 covers two things: always-on guardrails and on-demand skills (
guardrails: guardrails.md # c6andskills_dir: skills/ # c6, L9-L11). - The README counts eight editable components differently: "system prompt, guardrails, memory, tool guidance, subagents, skills, scripts, and plugins" (README.md L70-L73). Its eight is the seven c-slots that are editable, with c6 split into guardrails and skills.
- On Codex, c1, c3, c4 and the guardrails half of c6 all share one file,
AGENTS.md("c1/c3/c4/c6 persona, rules, durable facts, tool prose", prompts.py L475).
flowchart TB
subgraph OC ["OpenCode tree"]
oc1["systemprompt.md (c1)"]
oc6a["guardrails.md (c6 rules)"]
oc3["LongTermMEMORY.md (c3)"]
oc4["tool_descriptions/tool_guidance.md (c4)"]
oc6b["skills/name/SKILL.md (c6 skills)"]
oc5["sub_agents/name/AGENT.md (c5)"]
oc7["scripts/name.py (c7)"]
oc8["plugin/name.ts (c8)"]
end
subgraph CX ["Codex tree"]
cxa["AGENTS.md (c1 c3 c4 c6 rules)"]
cx6["agents skills dir: name/SKILL.md (c6 skills)"]
cx5["sub_agents/name/AGENT.md (c5, gated off)"]
cx7["scripts/name.py (c7)"]
cx8[".codex/hooks.json (c8)"]
end
c2["c2 context policy: owned by the framework, not editable"]
click oc1 href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/substrate/opencode.py#L44-L49" "OPENCODE_SURFACE always_on"
click oc6a href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/substrate/opencode.py#L44-L49" "OPENCODE_SURFACE always_on"
click oc3 href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/substrate/opencode.py#L44-L49" "OPENCODE_SURFACE always_on"
click oc4 href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/substrate/opencode.py#L44-L49" "OPENCODE_SURFACE always_on"
click oc6b href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/tree.py#L67" "skill regex"
click oc5 href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/tree.py#L69" "subagent regex"
click oc7 href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/tree.py#L70" "script regex"
click oc8 href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/tree.py#L73-L74" "plugin regex"
click cxa href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/substrate/codex.py#L41-L46" "CODEX_SURFACE always_on"
click cx6 href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/substrate/codex.py#L47-L55" "CODEX_SURFACE skills"
click cx5 href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/tree.py#L301-L314" "_check_subagents"
click cx7 href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/tree.py#L70" "script regex"
click cx8 href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/tree.py#L216-L251" "check_hooks"
click c2 href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/third_party/jb_runner/harness_baseline_oc/harness.yaml#L18" "harness.yaml c2 note"3.2 The baseline harnesses
The starting point (the "base tree", v0) is almost empty on purpose. JobBench's OpenCode baseline is four comment-only always-on files plus three README placeholders. Its Codex baseline is a two-line AGENTS.md:
Follow the task instructions in the task folder and write the final deliverables to the
specified output directory.(codex_base_harness/AGENTS.md L1-L2)
The reason is in the OpenCode system prompt comment: "EMPTY in v0 — v0 is the BARE stock OpenCode run (same as the real run1/run2/high)" (systemprompt.md L1-L4). The base rollouts must match the plain framework, so any gain comes from the edits. JobBench strips HTML comments when it composes the agent body (_strip_comments, "so a comment-only component renders to nothing"). The baseline therefore adds nothing to the agent's prompt. The comments still count toward the always-on cap, though: by character count the four files are 368 + 74 + 114 + 87 = 643 characters, which gives a 649-character always_on_text with the three "\n\n" separators. That is 2.7% of the 24,000 cap. WorkBuddy and LAB do not use these files. They build their base trees in code with write_tree (empty OpenCode files, or a one-sentence Codex AGENTS.md) and LAB base_harness.
How the base tree is read: read_tree keeps systemprompt.md, guardrails.md, LongTermMEMORY.md and tool_descriptions/tool_guidance.md. It skips scripts/README.md, sub_agents/README.md and plugin/README.md as stubs, and drops harness.yaml as off-surface. Following validate by hand gives the census {"always_on_chars": 649, "n_files": 4, "skills": 0, "sub_agents": 0, "scripts": 0, "plugins": 0} for OpenCode. For the Codex baseline it gives {"always_on_chars": 116, "n_files": 1, "skills": 0, "sub_agents": 0, "scripts": 0, "hooks": 0}.
3.3 Component by component
c1 · System prompt (always on)
| OpenCode | Codex | |
| Tree path | systemprompt.md | AGENTS.md (shared) |
| Runtime destination | JobBench: composed first into the workspace AGENTS.md, appended to OpenCode's stock provider prompt (compose_agent_body, render_opencode_tree). WorkBuddy: first part of _composed/prompt.j2 (compose_prompt) | copied to the workspace root, where codex exec -C <ws> injects it every turn (CODEX_ROOT_ENTRIES) |
| Rules | counts toward the 24,000-char cap; Jinja braces forbidden in WorkBuddy | counts toward the 8,000-char cap; must not be empty or deleted |
Baseline excerpt:
<!-- c1 system prompt. EMPTY in v0 — v0 is the BARE stock OpenCode run (same as the real
run1/run2/high). Evolution (later rounds) writes domain guidance here; render_opencode appends
it on top of OpenCode's stock provider prompt via AGENTS.md. Template tokens available if used:
{{env_description}} {{domain_focus}} {{date}} {{working_directory}}. -->Note the conflict: this comment advertises {{…}} template tokens, while the surface prompt's rule 2 bans doubled braces in always-on text ("a doubled {{* placeholder … kills the job"*, prompts.py L159-L161). The ban is enforced only on WorkBuddy (through assert_jinja_safe in validate_tree). JobBench interpolates the tokens itself with _interp. The comment is stripped before it reaches the agent.
c2 · Context policy (not on the surface)
Not covered by any SurfaceSpec path. The framework owns context assembly and compaction (harness.yaml L18). The code does not cover any way to edit it.
c3 · Long-term memory (always on)
| OpenCode | Codex | |
| Tree path | LongTermMEMORY.md | AGENTS.md (shared) |
| Runtime destination | JobBench: appended under a # Long-Term Memory heading in AGENTS.md (jobbench.py L436-L438) | inside AGENTS.md |
| Rules | same cap as c1 | same as c1 |
<!-- c3 memory. EMPTY in v0. Evolution adds durable, GENERAL domain facts here (never task-specific answers). -->The "never task-specific answers" note matches the answer-free rules in chapter 05. The GENERALIZE prompt, however, does ask for labelled workspace facts ("in this workspace…") (prompts.py L295-L313).
c4 · Tool guidance (always on)
| OpenCode | Codex | |
| Tree path | tool_descriptions/tool_guidance.md | AGENTS.md (shared) |
| Runtime destination | JobBench: appended to AGENTS.md (L431-L435). WorkBuddy: moved to the root as tool_guidance.md in staging (opencode.py L210-L213) | inside AGENTS.md |
| Rules | same cap; prose only, it does not change the tool implementations ("OpenCode itself … is FROZEN infrastructure", harness.yaml L2-L3) | same |
<!-- c4 tool guidance. EMPTY in v0. Evolution adds prose tool-usage guidance here. -->c5 · Subagents (own context, on demand)
| OpenCode | Codex | |
| Tree path | sub_agents/<name>/AGENT.md | sub_agents/<name>/AGENT.md |
| Frontmatter | mode: subagent and description: | description: |
| Runtime destination | .opencode/agent/<name>.md, invoked with the task tool (jobbench.py L476-L482; WorkBuddy flattens them the same way, oc_harness_agent.py L75-L84) | the stub says "the runner installs it as a [agents.] role" (codex.py L83-L85). But sub_agents is not in CODEX_ROOT_ENTRIES; the JobBench shell runner says "sub_agents are configured through CODEX_HOME" (run_benchmark_codex_cli_adapt.sh L258); LAB says "sub_agents/ never reaches the workspace (c5 is gated off on this substrate)" (run_codex_lab.py L39-L41) |
| Gate | BenchSpec.allow_subagents: True on JobBench and WorkBuddy for OpenCode (jobbench.py L519, workbuddy.py L664), False on LAB (lab.py L491) | False on all three benchmarks, so any Codex subagent fails validation with "sub_agents are not available on this deployment" |
<!-- c5 — delegated subagents (placeholder). The evolve loop may add a subagent here as
sub_agents/<name>/AGENT.md (YAML frontmatter with `mode: subagent` + a description + a
markdown body). render_opencode.py materializes each as .opencode/agent/<name>.md, which the
primary agent can invoke through the `task` tool. None defined in the v0 baseline. -->When to use one: "a bounded sub-task whose INPUT is large and whose ANSWER is small. It is a bad trade when the output is as big as the input." (prompts.py L117-L120). JobBench's renderer also adds mode: subagent if it is missing (_ensure_mode_subagent). Validation catches the omission earlier anyway.
c6 · Guardrails (always on) and skills (on demand)
| OpenCode | Codex | |
| Guardrails path | guardrails.md (also autotrigger_file) | AGENTS.md |
| Skill path | skills/<name>/SKILL.md, helpers at skills/<name>/scripts/* | .agents/skills/<name>/SKILL.md, helpers at .agents/skills/<name>/scripts/* |
| Frontmatter | name: equal to the directory + description: (first 600 chars) | description: required; name: optional but must equal the directory if present |
| Trigger | directory name must appear in always-on text; repaired line - When <desc>, load the skill. | repaired line - When <desc>, read .agents/skills/<name>/SKILL.md and follow it. |
| Skill-script compile | not checked | .py helpers must compile |
| Runtime destination | JobBench: guardrails under # Operating Guardrails in AGENTS.md; skills copied whole to .opencode/skills/<name>/ (jobbench.py L428-L430, L471-L474). WorkBuddy: an # AVAILABLE SKILLS index of name + description in the prompt template (opencode.py L151-L155) | .agents/ copied to the workspace root |
<!-- c6 guardrails. EMPTY in v0. Evolution adds always-on rules here. -->The baseline has no skills folder. The stub that write_tree adds explains the format (opencode.py L81-L86). Skills are the form the editor is told to prefer: "a SKILL for anything with a recognisable trigger … This is the default form for new instruction" (prompts.py L134-L146). On-demand text costs nothing until it is used, and always-on prose is "the expensive option".
c7 · Scripts (on demand, via Bash)
| OpenCode | Codex | |
| Tree path | scripts/<snake_case>.py | same |
| Rules | must compile; filename [a-z0-9_][a-z0-9_]*\.py; any python[3] scripts/x.py reference in a skill or always-on text must ship, unless the basename is reserved | same |
| Runtime destination | JobBench: ./scripts/ in the workspace, plus a generated # Helper scripts (run via Bash) listing in AGENTS.md using each script's docstring (jobbench.py L439-L445, L484-L486). WorkBuddy: ~/harness_scripts/ (opencode.py L162-L168) | scripts/ at the workspace root |
<!-- c7 (helper-scripts-via-Bash) — deterministic Python helpers (placeholder).
The evolve loop may add scripts here, e.g. scripts/validate_workbook.py. render_opencode.py
copies this dir into the temp workspace as ./scripts/, and a skill/guardrail instructs the
agent to run them via Bash (e.g. `python scripts/validate_workbook.py output/Foo.xlsx`).
This is how we get c7's deterministic-fix value without an OpenCode TS plugin.
Each script must be stdlib-or-pip importable and runnable as `python scripts/<name>.py`.
None defined in the v0 baseline. -->"stdlib-or-pip importable" is a convention only. validate checks syntax, not imports.
c8 · Plugins (OpenCode) / lifecycle hooks (Codex): inside the tool loop
| OpenCode | Codex | |
| Tree path | plugin/<name>.ts | .codex/hooks.json (exactly one file) |
| Rules | name matches _NAME; contents not validated (no TypeScript check in tree.py) | full schema check by check_hooks (events, list shape, type: command, non-empty command) |
| Runtime destination | .opencode/plugin/<name>.ts, auto-discovered (jobbench.py L488-L494) | .codex/ copied to the workspace root; enabled by --dangerously-bypass-hook-trust |
A plugin exports a server function returning Hooks; use it for what prose rules cannot do —
a deterministic RECHECK in the tool loop rather than a static instruction:
- "tool.execute.after" — inspect/transform a tool's output (e.g. after a Write, verify the
deliverable; may call the LLM via the plugin client for a semantic check)
- "experimental.chat.system.transform" — inject dynamic, per-task guidance
- "tool" — register a brand-new deterministic tool (e.g. a workbook validator)
- "chat.params" — adjust model paramsWhy c8 matters: "the only component that acts in the tool loop rather than in the prompt, and therefore the only way to enforce something the model cannot skip. Prose can be stopped reading; a hook cannot." (prompts.py L122-L124). It also costs nothing in always-on budget. Why plugins get no content check while hooks do: the code does not say. A plausible reading of the check_hooks docstring is that a malformed hooks file fails silently, so it needs a gate. No TypeScript check exists here.
3.4 Class diagram
classDiagram
class SurfaceSpec {
+name : str
+always_on : tuple
+always_on_max : int
+always_on_cap_reason : str
+skill_dir : str
+skill_name_rule : str
+trigger_hint : str
+autotrigger_file : str
+trigger_line : str
+subagent_requires_mode : bool
+plugin_dir : str or None
+hooks_file : str or None
+hook_events : frozenset
+compile_skill_scripts : bool
+reject_empty_always_on : bool
+stubs : tuple
}
class OPENCODE_SURFACE {
name = opencode
always_on_max = 24000
skill_dir = skills
skill_name_rule = must_match
plugin_dir = plugin
hooks_file = None
}
class CODEX_SURFACE {
name = codex
always_on_max = 8000
skill_dir = .agents/skills
skill_name_rule = overrides_dir
plugin_dir = None
hooks_file = .codex/hooks.json
}
class OpencodeSubstrate {
+name = oc
+surface() SurfaceSpec
+materialise()
}
class CodexSubstrate {
+name = cx
+surface() SurfaceSpec
+materialise()
}
class OcAlwaysOn {
systemprompt.md c1
guardrails.md c6
LongTermMEMORY.md c3
tool_guidance.md c4
}
class OcSkill {
skills/name/SKILL.md c6
name must match dir
}
class OcSubagent {
sub_agents/name/AGENT.md c5
mode subagent required
}
class OcScript {
scripts/name.py c7
}
class OcPlugin {
plugin/name.ts c8
}
class CxAgentsMd {
AGENTS.md c1 c3 c4 c6
never empty
}
class CxSkill {
.agents/skills/name/SKILL.md c6
scripts compile
}
class CxSubagent {
sub_agents/name/AGENT.md c5
gated off
}
class CxScript {
scripts/name.py c7
}
class CxHooks {
.codex/hooks.json c8
schema checked
}
class Stage {
+label : str
+dim : str
+tree : Tree
+kept_base : bool
+fell_back : bool
+error : str
}
SurfaceSpec <|.. OPENCODE_SURFACE : instance
SurfaceSpec <|.. CODEX_SURFACE : instance
OpencodeSubstrate --> OPENCODE_SURFACE : surface()
CodexSubstrate --> CODEX_SURFACE : surface()
OPENCODE_SURFACE *-- OcAlwaysOn
OPENCODE_SURFACE *-- OcSkill
OPENCODE_SURFACE *-- OcSubagent
OPENCODE_SURFACE *-- OcScript
OPENCODE_SURFACE *-- OcPlugin
CODEX_SURFACE *-- CxAgentsMd
CODEX_SURFACE *-- CxSkill
CODEX_SURFACE *-- CxSubagent
CODEX_SURFACE *-- CxScript
CODEX_SURFACE *-- CxHooks
Stage --> SurfaceSpec : tree validated against
click SurfaceSpec href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/tree.py#L40-L60" "SurfaceSpec"
click OPENCODE_SURFACE href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/substrate/opencode.py#L42-L88" "OPENCODE_SURFACE"
click CODEX_SURFACE href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/substrate/codex.py#L39-L103" "CODEX_SURFACE"
click OpencodeSubstrate href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/substrate/opencode.py#L173-L224" "OpencodeSubstrate"
click CodexSubstrate href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/substrate/codex.py#L109-L147" "CodexSubstrate"
click OcAlwaysOn href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/substrate/opencode.py#L44-L49" "OpenCode always_on"
click OcSkill href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/tree.py#L267-L298" "_check_skills"
click OcSubagent href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/tree.py#L301-L314" "_check_subagents"
click OcScript href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/tree.py#L254-L264" "_py_compiles"
click OcPlugin href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/tree.py#L73-L74" "plugin regex"
click CxAgentsMd href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/substrate/codex.py#L41-L46" "Codex always_on"
click CxSkill href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/substrate/codex.py#L47-L55" "Codex skill rules"
click CxSubagent href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/tree.py#L301-L314" "_check_subagents"
click CxScript href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/tree.py#L254-L264" "_py_compiles"
click CxHooks href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/tree.py#L216-L251" "check_hooks"
click Stage href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/recursion.py#L41-L55" "Stage"3.5 Caps and limits at a glance
| Limit | Value | Where |
| Always-on cap, OpenCode | 24,000 characters | opencode.py L50 |
| Always-on cap, Codex | 8,000 characters (codex itself silently cuts AGENTS.md at 32,768 bytes) | codex.py L42-L46 |
| Frontmatter window | first 600 characters of SKILL.md / AGENT.md | tree.py L203, L271, L309 |
| Compile-error detail | 160 characters | tree.py L264 |
| Off-surface paths listed in retry feedback | first 6 | recursion.py L219 |
| Stage error kept | 400 characters | recursion.py L247 |
| Proposal attempts per stage | STAGE_ATTEMPTS = 2 (one retry) | recursion.py L36 |
| Regex cache | lru_cache(maxsize=8) | tree.py L63 |
4. Edits are diffs: omitted-is-inherited
The contract in the module docstring: "The edit contract is omitted-is-inherited: a proposal emits only what it changes." (tree.py L19-L20).
In practice the "diff" is never a patch file. The editor gets a copy of the current harness in context/harness/ and edits it in place:
The harness is in `context/harness/`, and it is YOURS TO EDIT. Open the files, change them in
place with your normal tools, and create new ones where a new component is the right lever.
Your edits on disk ARE the proposal -- there is no separate format to reproduce them in, and
nothing you write in this reply changes the harness.
- Files you do not touch are inherited unchanged. Do not rewrite a file to keep it the same.
- Delete a file to remove that component.
- If the evidence does not argue for any change, leave everything as it is and say KEEP_BASE
in your notes.(prompts.py L181-L190; the same text for Codex at L280-L289)
How the code makes that true:
- context.build copies the current harness directory (written by
write_tree, so README stubs included) intocontext/harness/with _copy_tree. - The proposer session edits files. Proposer._harvest copies
context/harnessout before the session directory is removed (harvest="context/harness", recursion.py L192). read_treereads the whole harvested directory. An untouched file is therefore present with its old text, which is what "inherited" means. A deleted file is absent, so it shows up indiff_summary(...)["removed"].- The new tree replaces the old one as a whole. There is no merge step, because the workspace already started as the old tree.
Three consequences:
- KEEP_BASE is cheap and visible. An editor that changes nothing produces a tree equal to the fallback, and
_accept_treesetskept_base=True(recursion.py L119-L120). The prompt treats this as a real answer: "Adaptation that the evidence does not support costs context and buys nothing." (L333-L335). - Deleting everything is rejected. An empty
read_treeresult gives "context/harness/* came back empty -- edit the files, do not delete them all"* (recursion.py L213-L215). - Deleting an always-on file. On OpenCode this is legal:
always_on_texttreats it as"", andwrite_treelater recreates it as an empty file. On Codex, deletingAGENTS.mdempties the body and tripsreject_empty_always_on.
The surface prompt is the only place where the editor is told the rules. prompts.propose, aggregate and compose_round each include st.surface_md (or st.tts_surface for the TTS pack) for the surface's name. That block's "THREE RULES THAT DISCARD THE WHOLE PROPOSAL" are exactly the checks validate enforces: trigger lines (_check_skills), no template braces (WorkBuddy's validate_tree), and every named script must exist (_check_script_refs) (prompts.py L148-L167).
5. Validation in the loop: repair, reject, retry, fall back
5.1 The stage loop
_run_stage runs at most STAGE_ATTEMPTS = 2 proposer calls:
for attempt in range(1, STAGE_ATTEMPTS + 1):
try:
reply = prop.call(
mission
+ (
f"\n\n# YOUR PREVIOUS ATTEMPT WAS REJECTED\n\n{err}\n\n"
f"Fix it in `context/harness/` and reply again."
if err
else ""
),
tree.root,
f"{label}.a{attempt}",
harvest="context/harness",
max_attempts=TRANSPORT_ATTEMPTS,
)
except ProposerError as exc:
...
breakEach attempt ends in one of these ways:
| Outcome | Code | Next |
Transport failure (ProposerError) | L195-L200 | break, no content retry, then fall back |
| Workspace did not come back | L205-L210 | retry with "edit the files in context/harness/ in place" |
| Tree empty | L211-L215 | retry |
| Off-surface files present | L216-L224 | retry with the list of rejected paths and naming examples. The whole proposal is rejected, not just those files |
_accept_tree error | L226-L229 | write the (auto-repaired) tree back to context/harness/, retry with the joined error text |
| Accepted | L230-L240 | Stage(tree=new, kept_base=…, notes=<notes> block) |
After two failures the stage falls back: Stage(tree=dict(fallback), fell_back=True, error=err[:400]) (L242-L251). The fallback is the harness the stage started from: the base tree in parallel mode (L376), and the current composed harness in sequential mode (L498). This is "omitted-is-inherited" at the stage level: a failed stage inherits everything. A fallen-back stage still gets rolled, because it is a valid harness, and ModeResult.fell_back lists such stages (L75-L77).
Why write the rejected tree back before the retry (L228)? The editor then sees its own work plus the auto-trigger repair, and only has to fix what the error names. It does not start over.
5.2 Lifecycle of one edit
stateDiagram-v2 [*] --> Proposed : editor edits context/harness Proposed --> Harvested : Proposer harvest Proposed --> FellBack : ProposerError (transport) Harvested --> Rejected : missing, empty, or off-surface files Harvested --> Repaired : autotrigger adds trigger lines Repaired --> Validated : validate, adapter.validate_tree, verdict tripwire pass Repaired --> Rejected : any ValueError Rejected --> Proposed : attempt 1, error text fed back Rejected --> FellBack : attempt 2 failed Validated --> Probed : write_tree then one rollout FellBack --> Probed : inherited tree is rolled too Probed --> Composed : INV and DOM candidates pooled into agg or c stage Composed --> Probed : composed final is rolled Probed --> Selected : finals enter blind selection Selected --> Adopted : chosen, stored in domain library Selected --> NotChosen : another final chosen Adopted --> [*] NotChosen --> [*]
The "Composed" step is itself a stage. It goes through the same _run_stage → _accept_tree path, with dim="AGG" (parallel, L408-L429) or dim="COMPOSE" (sequential, L534-L555). Its editor sees each pooled candidate's harness, a unified harness.diff against the current tree, and its rollout (_write_pool). "Selected" and "Adopted" happen in schedule: the chosen label's tree goes to mode_lib.add(...) only if selection succeeded and returned experience. Chapters 03 and 06 cover those steps.
5.3 The validation pipeline as a flowchart
flowchart TD A["harvested workspace"] --> B["read_tree with rejected list"] B -->|"rejected not empty"| R["retry: off-surface paths"] B --> C["autotrigger repairs missing triggers"] C --> D["diff_summary vs fallback, sets kept_base"] D --> E["validate: cap, empty, skills, subagents, compile, hooks, script refs"] E -->|"ValueError"| W["write_tree back to context/harness, retry"] E --> F["adapter.validate_tree"] F -->|"ValueError"| W F --> G["assert_tree_has_no_grader_verdicts"] G -->|"ValueError"| W G --> H["accepted Stage"] click B href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/tree.py#L127-L148" "read_tree" click R href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/recursion.py#L216-L224" "off-surface rejection" click C href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/tree.py#L192-L213" "autotrigger" click D href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/tree.py#L393-L404" "diff_summary" click E href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/tree.py#L336-L390" "validate" click W href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/recursion.py#L226-L229" "write back and retry" click F href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/spec.py#L242-L245" "BenchmarkAdapter.validate_tree" click G href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/context.py#L493-L518" "grader verdict tripwire" click H href "https://github.com/google-research/harness2/blob/1cfe254e8d25b15f37048c3730134686abe60335/harness2/core/recursion.py#L230-L240" "accepted Stage"
5.4 Catalogue of validation failures
| Category | Failure | Caught by | Framework |
| Paths | absolute path or .. | normalize_rel → is_allowed false → off-surface | both |
| Paths | uppercase or bad characters in a component name, loose files in a skill dir, nested script dirs, harness.yaml / runtime AGENTS.md (OpenCode) | read_tree(rejected=…) | both |
| Caps | always-on text over the cap | validate | both (24k / 8k) |
| Caps | empty always-on body | validate | Codex |
| Skills | missing description: | _check_skills | both |
| Skills | name: missing or different from the directory | _check_skills (must_match) | OpenCode |
| Skills | name: different from the directory | _check_skills (overrides_dir) | Codex |
| Skills | skill never named in always-on text (and auto-repair skipped because of the cap) | _check_skills | both |
| Subagents | any subagent where not allowed | _check_subagents | Codex always; OpenCode on LAB |
| Subagents | missing mode: subagent | _check_subagents | OpenCode |
| Subagents | missing description: | _check_subagents | both |
| Scripts | scripts/*.py does not compile | _py_compiles | both |
| Scripts | skill scripts/*.py does not compile | _py_compiles | Codex only |
| Scripts | invoked script not shipped (and not reserved) | _check_script_refs | both |
| Hooks | invalid JSON / bad shape / unknown event / non-command hook / empty command | check_hooks | Codex |
| Benchmark | Jinja braces in composed prompt | WorkBuddy validate_tree → assert_jinja_safe | OpenCode on WB |
| Benchmark | lost output/ or read_doc.py rule | LAB validate_tree | LAB |
| Leakage | grader-verdict marker in a harness file name or text | assert_tree_has_no_grader_verdicts | both |
6. Diversity: line-Jaccard and collapse
def lines(t: Tree) -> set[str]:
return {f"{rel}:{ln}" for rel, c in t.items() for ln in c.splitlines() if ln.strip()}
sets = {n: lines(trees[n]) for n in names}
sims = []
for i, a in enumerate(names):
for b in names[i + 1 :]:
u = sets[a] | sets[b]
sims.append(len(sets[a] & sets[b]) / len(u) if u else 1.0)
distinct = len({tuple(sorted(t.items())) for t in trees.values()})
return {
"n": len(names),
"n_distinct": distinct,
"mean_line_jaccard": round(sum(sims) / len(sims), 4),
}- Unit of comparison. Each tree becomes a set of
"<path>:<line>"strings, with blank lines ignored. The path prefix means the same sentence in two different files does not count as shared. Because it is a set, a line repeated inside a file (such as the two---frontmatter fences) counts once. - Similarity. For every pair of trees, Jaccard = |A ∩ B| / |A ∪ B| (1.0 if both are empty).
mean_line_jaccardis the mean over all pairs, rounded to 4 places. n_distinct. The number of byte-identical trees.- Fewer than two trees.
{"n": n, "n_distinct": n, "mean_line_jaccard": 1.0}.
Where it is used. Only in run_parallel, over the k composed finals agg1..aggk. The result goes to ModeResult.similarity and into the task summary through to_dict() (L102, schedule.py L512). run_sequential does not compute it (similarity stays None). Nothing in the code reads the value back to select, gate or retry. It is a diagnostic only.
What collapse means. Parallel mode spends its budget on k independent compositions so that the blind selector (chapter 06) has real alternatives. All k aggregators read the same base evidence and the same pool of 2k candidates (L404-L429), so they can converge on nearly the same harness. When that happens mean_line_jaccard approaches 1.0 and n_distinct drops towards 1. The k finals are then effectively one candidate rolled k times, and the "width" of the method has collapsed. The prompts try to prevent this by giving each aggregator its own index (prompts.aggregate(..., k=k, index=…), L415). The metric makes the result visible, as the docstring says: "so collapse to one body is visible." The code does not set a threshold for "collapsed".
7. Worked example: one OpenCode edit, end to end
Setting: JobBench on OpenCode, parallel mode, stage inv1. The fallback is the JobBench baseline tree from §3.2 (four comment-only always-on files, 649 always-on characters). The evidence shows the agent hand-checking spreadsheet totals and getting them wrong. The values below are traced by hand from the code; nothing was executed.
7.1 Attempt 1: the proposal
The editor creates one new file in context/harness/ and leaves the rest alone:
--- current/skills/xlsx_check/SKILL.md
+++ inv1/skills/xlsx_check/SKILL.md
@@ -0,0 +1,7 @@
+---
+name: xlsx_check
+description: a deliverable is an Excel workbook.
+---
+
+1. After writing the workbook, run `python3 skills/xlsx_check/scripts/check_totals.py output/<file>.xlsx`.
+2. Fix any row it reports before finishing.It forgot two things: the trigger line, and the helper script.
Read back. read_tree(OPENCODE_SURFACE, workspace, rejected=[]) returns the four always-on files (inherited, unchanged) plus skills/xlsx_check/SKILL.md. The README stubs are skipped and nothing is rejected (skills/xlsx_check/SKILL.md matches the skill regex).
Repair. autotrigger finds xlsx_check absent from the always-on text. It reads the description, a deliverable is an Excel workbook (trailing full stop removed), formats trigger_line, and appends to the non-empty guardrails.md with cur.rstrip() + "\n" + line:
<!-- c6 guardrails. EMPTY in v0. Evolution adds always-on rules here. -->
- When a deliverable is an Excel workbook, load the `xlsx_check` skill.The console prints [inv1] auto-triggered skill(s): xlsx_check.
Diff. diff_summary(base, new) → added: [skills/xlsx_check/SKILL.md], modified: [guardrails.md], removed: [], so kept_base=False.
Validate. Cap: 649 + 73 = 722 characters, well under 24,000. Skills: description: present, name: xlsx_check matches the directory, and the name is now in the body. Subagents: none. Compile: no scripts/*.py. Script refs: the script_ref regex finds skills/xlsx_check/scripts/check_totals.py in SKILL.md. It is not shipped and check_totals.py is not reserved, so:
ValueError: skills/xlsx_check/SKILL.md invokes `skills/xlsx_check/scripts/check_totals.py` but that file is not in the harnessWrite-back. write_tree(OPENCODE_SURFACE, new, context/harness) writes the repaired tree (with the trigger line) back. Attempt 2's mission ends with:
# YOUR PREVIOUS ATTEMPT WAS REJECTED
skills/xlsx_check/SKILL.md invokes `skills/xlsx_check/scripts/check_totals.py` but that file is not in the harness
Fix it in `context/harness/` and reply again.7.2 Attempt 2: the fix
The editor adds the helper in the right place (a sibling skills/xlsx_check/check_totals.py would have been rejected as off-surface):
--- current/skills/xlsx_check/scripts/check_totals.py
+++ inv1/skills/xlsx_check/scripts/check_totals.py
@@ -0,0 +1,9 @@
+"""Report sheets whose line items do not add up to the stated total."""
+import sys
+
+def main(path: str) -> int:
+ print(f"checked {path}")
+ return 0
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1]))autotrigger repairs nothing (the name is already in guardrails.md). validate passes. The file matches skill_script, so _check_script_refs is satisfied. On OpenCode it is not compiled, because compile_skill_scripts=False; on Codex it would be. JobBench's validate_tree adds nothing, and the verdict tripwire finds no marker. The census:
{"always_on_chars": 722, "n_files": 6, "skills": 1, "sub_agents": 0, "scripts": 0, "plugins": 0}(722 is the 649-character base body plus 73: the trigger line is 73 characters with its newline, and the rstrip() / joining newline cancel out. scripts counts only top-level scripts/*.py, so the skill's helper is not counted.) The stage returns Stage(label="inv1", dim="INV", tree=new, kept_base=False, notes=<the reply's <notes> block>).
7.3 The written tree
_roll calls write_tree(OPENCODE_SURFACE, new, work_dir/harnesses/inv1) (stubs on), which gives:
harnesses/inv1/
├── systemprompt.md (inherited)
├── guardrails.md (modified: +1 trigger line)
├── LongTermMEMORY.md (inherited)
├── tool_descriptions/tool_guidance.md (inherited)
├── skills/README.md (stub)
├── skills/xlsx_check/SKILL.md (added)
├── skills/xlsx_check/scripts/check_totals.py (added)
├── scripts/README.md (stub)
├── sub_agents/README.md (stub)
└── plugin/README.md (stub)JobBench's render_opencode_tree then turns this into the runtime layout. AGENTS.md holds # Operating Guardrails + the trigger line (comments stripped). .opencode/skills/xlsx_check/ is copied whole, so its scripts/ comes along. The run is "probed" once. Later, inv1 enters the pool for agg1..aggk as pool/inv1/harness/ + harness.diff + rollout.md.
7.4 What diversity would report
Suppose two finals came out as the base tree and this inv1 tree. The base has 7 distinct non-blank path:line entries (4 + 1 + 1 + 1). The new tree has those 7, plus 1 trigger line, plus 5 distinct SKILL.md lines (the two --- fences count once), plus 7 script lines: 20 in all. Jaccard = 7 / 20 = 0.35, n_distinct = 2. Two byte-identical finals would give 1.0 and n_distinct = 1, which is a collapse.
Key takeaways
- A harness is
dict[path, text]filtered by a per-frameworkSurfaceSpec. Two instances exist (OPENCODE_SURFACE,CODEX_SURFACE), and all logic intree.pyis shared between them. - c1..c8 are not eight files. c2 (context policy) is not editable. c6 is both guardrails and skills. On Codex, c1, c3, c4 and the guardrails all live in
AGENTS.md. c8 is TS plugins on OpenCode and.codex/hooks.jsonon Codex. - Edits are in-place file changes; untouched files are inherited. The whole harvested directory becomes the new tree, deletion removes a component, and an unchanged tree is a legitimate
KEEP_BASE. - Validation turns silent framework drops into loud errors: a missing
description:, aname:mismatch, an untriggered skill, a missing helper script, a malformed hooks file. It collects all errors at once so one retry can fix them all. - Missing triggers are repaired automatically, within the cap. The repaired tree is written back so the editor sees the repair on its retry.
- One retry, then inherit. Two failed attempts (or one transport error) leave the stage holding its fallback tree, which is still rolled.
- Diversity is a diagnostic only. Mean pairwise line-Jaccard and the distinct count over parallel finals make collapse visible. Nothing in the code acts on it.
Where to go next
- 00 · Start here: the map of the guide.
- 01 · Architecture overview: where the tree sits among method, substrate and benchmark.
- 02 · Code structure: the module layout around
core/tree.py. - 03 · One task, end to end: the stages that call
_run_stageand_roll, in order. - 05 · Evidence and safety:
context.build, leak globs and the grader-verdict tripwire that_accept_treealso runs. - 06 · Budget, selection and judging: how the finals compared by
diversityare selected and adopted. - 07 · Executors and benchmarks: how each substrate and benchmark materialises the tree (
materialise,render_opencode_tree,compose_prompt). - 08 · Setup and running: running the demo and benchmarks that produce these trees.