Octonote AI
Make your own →

04 · The harness tree

Sunil Tiwari· Oct 27, 2026 · 42 min

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:

CallerWhat it uses from tree.pyWhy
schedule.run_taskread_tree(surface, adapter.base_harness())Loads the unmodified starting harness (the "base tree") for a task
recursion._run_stageread_tree(..., rejected=...), write_treeReads the editor's edited workspace back, and writes a rejected tree back for the retry
recursion._accept_treeautotrigger, diff_summary, validate, RESERVED_SCRIPTSRepairs, then accepts or rejects one proposal
recursion._rollwrite_treeWrites a candidate to work_dir/harnesses/<label> before running it
recursion.run_paralleldiversityMeasures how similar the k final compositions are
context._write_poolread_treeBuilds a harness.diff between the current harness and each pooled candidate
library.Library / _inventoryread_tree, write_tree(stubs=False), always_on_textStores adopted harnesses per domain and summarises them
schedule._tree_is_basediff_summaryRecords whether the chosen harness equals the base
OpencodeSubstrate.materialise / CodexSubstrate.materialiseis_tree, normalize_relRefuse to install a directory that is not a harness tree
Benchmark adapters (JobBench, WorkBuddy, LAB)is_tree, write_tree, always_on_textBuild 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

NameLineValue / signatureWhat it is for
Tree31dict[str, str]A harness in memory: relative POSIX path → file text. Everything else in the module takes or returns one.
RESERVED_SCRIPTS34frozenset({"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).
_NAME37r"[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_\-]*"

(tree.py L31-L37)

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.

FieldTypeMeaningOpenCode valueCodex valueRead by
namestrSurface id. Also the key that prompts._st uses to pick the matching "WHAT YOU CAN CHANGE" text"opencode""codex"_rx, prompts
always_ontuple[str, ...]Files that are in the agent's context on every run. Order matters for always_on_textsystemprompt.md, guardrails.md, LongTermMEMORY.md, tool_descriptions/tool_guidance.mdAGENTS.mdis_allowed, is_tree, write_tree, always_on_text, validate
always_on_maxintCharacter cap on the joined always-on text24_0008_000validate, autotrigger
always_on_cap_reasonstrExplanation 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_dirstrRoot folder of skillsskills.agents/skillsregexes, autotrigger, _check_skills
skill_name_rulestrHow 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_hintstr (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_filestrThe always-on file the automatic repair appends toguardrails.mdAGENTS.mdautotrigger
trigger_linestr (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_modeboolWhether AGENT.md must carry mode: subagentTrueFalse_check_subagents
plugin_dirstr | NoneFolder of TypeScript plugins (c8 on OpenCode)pluginNone_res, is_allowed, validate census
hooks_filestr | NoneThe single hooks JSON file (c8 on Codex)None.codex/hooks.jsonis_allowed, validate, check_hooks
hook_eventsfrozenset[str]Event names the framework dispatchesemptyPreToolUse, PostToolUse, PermissionRequest, PreCompact, PostCompact, SessionStart, SubagentStart, SubagentStop, UserPromptSubmit, Stopcheck_hooks
compile_skill_scriptsboolWhether .py files under a skill's scripts/ must compile tooFalseTruevalidate
reject_empty_always_onboolWhether an empty always-on body is an errorFalseTruevalidate
stubstuple[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 tree4 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], ...] = ()

(tree.py L40-L60)

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

(tree.py L65-L75)

KeyMatches (with fullmatch)OpenCode exampleCodex exampleNotes
skill<skill_dir>/<name>/SKILL.md, capturing <name>skills/doc_review/SKILL.md.agents/skills/doc_review/SKILL.mdSKILL.md is case-sensitive
skill_scriptany file directly under <skill_dir>/<name>/scripts/skills/doc_review/scripts/check.py.agents/skills/doc_review/scripts/run.shThe filename may contain uppercase letters, . and -, and any extension. One folder deep only
subagentsub_agents/<name>/AGENT.mdsame on bothsame on bothSame path on both frameworks
scriptscripts/<snake>.pyscripts/check_totals.pysameNo hyphens, no uppercase: scripts/check-totals.py is off the surface
script_refsearched (not fullmatched) in text: python or python3, whitespace, then a script pathpython3 scripts/x.py, python skills/s/scripts/y.pypython3 .agents/skills/s/scripts/y.pyUsed 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

FunctionSignatureReturnsBehaviour and why
normalize_rel(rel: str) -> strcleaned pathTurns \ 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) -> boolon 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 namesNames come from the skill regex group; a skill exists only if its SKILL.md does.
subagent_names(surface, tree) -> list[str]sorted subagent namesSame idea for sub_agents/<name>/AGENT.md.
_stub_paths(surface) -> frozenset[str]stub pathsUsed 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("/")

(tree.py L82-L89)

Some concrete answers from is_allowed, worked out from the regexes above (both columns use the real surface values):

PathOpenCodeCodexWhy
systemprompt.mdyesnoalways-on only on OpenCode
AGENTS.mdnoyesalways-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.mdyesnoCodex skills live in .agents/skills/
skills/Doc/SKILL.mdnonouppercase is not allowed by _NAME
skills/doc_review/notes.mdnonoonly SKILL.md and scripts/* are allowed inside a skill
skills/doc_review/check.pynonoa helper "loose in the skill directory is OFF THE EDITABLE SURFACE" (prompts.py L163-L167)
scripts/check_totals.pyyesyessnake_case .py
scripts/check-totals.pynonothe script regex has no -
scripts/helpers/x.pynonoone level only
plugin/verify.tsyesnoCodex has no plugin dir
.codex/hooks.jsonnoyesthe hooks file is on the Codex surface only
harness.yamlnonoit is a manifest, not a component
../evil.mdnononormalize_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.yaml manifest 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")

(tree.py L164-L184)

  1. Delete every file at dest that 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 when write_tree writes into an existing context/harness/ for a retry.
  2. Make sure every always-on file exists, as an empty file if the tree lacks it. A tree from which the editor deleted guardrails.md is still a valid OpenCode tree on disk (and is_tree stays true).
  3. Write every tree entry, passing each path through normalize_rel again (defence in depth).
  4. Write the README stubs unless stubs=False. The library stores adopted harnesses with stubs=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)

(tree.py L200-L212)

  • 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.md are searched for a description: 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 validate call 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.

CheckError 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 entriesL237-L239
each entry is an object with a hooks list…: <Event>[i] needs a hooks listL241-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

FunctionWhat it appends to errsWhy
_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:

  1. Always-on length over the cap → always-on content is N chars, over the CAP cap; <reason> (L350-L354).
  2. 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).
  3. _check_skills, then _check_subagents (L361-L362).
  4. Compile every scripts/*.py, plus skill scripts ending in .py when compile_skill_scripts is set (L364-L370).
  5. check_hooks if the surface has a hooks file and the tree contains it (L372-L373).
  6. _check_script_refs (L375).
  7. 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 out

(tree.py L379-L390)

The 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)

(recursion.py L121-L131)

  • 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 mention output/ and read_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 SurfaceSpec path maps to c2 on either framework.
  • c6 covers two things: always-on guardrails and on-demand skills (guardrails: guardrails.md # c6 and skills_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)

OpenCodeCodex
Tree pathsystemprompt.mdAGENTS.md (shared)
Runtime destinationJobBench: 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)
Rulescounts toward the 24,000-char cap; Jinja braces forbidden in WorkBuddycounts 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}}. -->

(systemprompt.md L1-L4)

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)

OpenCodeCodex
Tree pathLongTermMEMORY.mdAGENTS.md (shared)
Runtime destinationJobBench: appended under a # Long-Term Memory heading in AGENTS.md (jobbench.py L436-L438)inside AGENTS.md
Rulessame cap as c1same as c1
<!-- c3 memory. EMPTY in v0. Evolution adds durable, GENERAL domain facts here (never task-specific answers). -->

(LongTermMEMORY.md L1)

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)

OpenCodeCodex
Tree pathtool_descriptions/tool_guidance.mdAGENTS.md (shared)
Runtime destinationJobBench: appended to AGENTS.md (L431-L435). WorkBuddy: moved to the root as tool_guidance.md in staging (opencode.py L210-L213)inside AGENTS.md
Rulessame 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. -->

(tool_guidance.md L1)

c5 · Subagents (own context, on demand)

OpenCodeCodex
Tree pathsub_agents/<name>/AGENT.mdsub_agents/<name>/AGENT.md
Frontmattermode: 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)
GateBenchSpec.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. -->

(sub_agents/README.md L1-L4)

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)

OpenCodeCodex
Guardrails pathguardrails.md (also autotrigger_file)AGENTS.md
Skill pathskills/<name>/SKILL.md, helpers at skills/<name>/scripts/*.agents/skills/<name>/SKILL.md, helpers at .agents/skills/<name>/scripts/*
Frontmattername: equal to the directory + description: (first 600 chars)description: required; name: optional but must equal the directory if present
Triggerdirectory 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 compilenot checked.py helpers must compile
Runtime destinationJobBench: 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. -->

(guardrails.md L1)

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)

OpenCodeCodex
Tree pathscripts/<snake_case>.pysame
Rulesmust 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 reservedsame
Runtime destinationJobBench: ./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. -->

(scripts/README.md L1-L7)

"stdlib-or-pip importable" is a convention only. validate checks syntax, not imports.

c8 · Plugins (OpenCode) / lifecycle hooks (Codex): inside the tool loop

OpenCodeCodex
Tree pathplugin/<name>.ts.codex/hooks.json (exactly one file)
Rulesname 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 params

(plugin/README.md L6-L12)

Why 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

LimitValueWhere
Always-on cap, OpenCode24,000 charactersopencode.py L50
Always-on cap, Codex8,000 characters (codex itself silently cuts AGENTS.md at 32,768 bytes)codex.py L42-L46
Frontmatter windowfirst 600 characters of SKILL.md / AGENT.mdtree.py L203, L271, L309
Compile-error detail160 characterstree.py L264
Off-surface paths listed in retry feedbackfirst 6recursion.py L219
Stage error kept400 charactersrecursion.py L247
Proposal attempts per stageSTAGE_ATTEMPTS = 2 (one retry)recursion.py L36
Regex cachelru_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:

  1. context.build copies the current harness directory (written by write_tree, so README stubs included) into context/harness/ with _copy_tree.
  2. The proposer session edits files. Proposer._harvest copies context/harness out before the session directory is removed (harvest="context/harness", recursion.py L192).
  3. read_tree reads 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 in diff_summary(...)["removed"].
  4. 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_tree sets kept_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_tree result 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_text treats it as "", and write_tree later recreates it as an empty file. On Codex, deleting AGENTS.md empties the body and trips reject_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:
            ...
            break

(recursion.py L180-L200)

Each attempt ends in one of these ways:

OutcomeCodeNext
Transport failure (ProposerError)L195-L200break, no content retry, then fall back
Workspace did not come backL205-L210retry with "edit the files in context/harness/ in place"
Tree emptyL211-L215retry
Off-surface files presentL216-L224retry with the list of rejected paths and naming examples. The whole proposal is rejected, not just those files
_accept_tree errorL226-L229write the (auto-repaired) tree back to context/harness/, retry with the joined error text
AcceptedL230-L240Stage(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

CategoryFailureCaught byFramework
Pathsabsolute path or ..normalize_rel → is_allowed false → off-surfaceboth
Pathsuppercase 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
Capsalways-on text over the capvalidateboth (24k / 8k)
Capsempty always-on bodyvalidateCodex
Skillsmissing description:_check_skillsboth
Skillsname: missing or different from the directory_check_skills (must_match)OpenCode
Skillsname: different from the directory_check_skills (overrides_dir)Codex
Skillsskill never named in always-on text (and auto-repair skipped because of the cap)_check_skillsboth
Subagentsany subagent where not allowed_check_subagentsCodex always; OpenCode on LAB
Subagentsmissing mode: subagent_check_subagentsOpenCode
Subagentsmissing description:_check_subagentsboth
Scriptsscripts/*.py does not compile_py_compilesboth
Scriptsskill scripts/*.py does not compile_py_compilesCodex only
Scriptsinvoked script not shipped (and not reserved)_check_script_refsboth
Hooksinvalid JSON / bad shape / unknown event / non-command hook / empty commandcheck_hooksCodex
BenchmarkJinja braces in composed promptWorkBuddy validate_tree → assert_jinja_safeOpenCode on WB
Benchmarklost output/ or read_doc.py ruleLAB validate_treeLAB
Leakagegrader-verdict marker in a harness file name or textassert_tree_has_no_grader_verdictsboth

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),
    }

(tree.py L413-L427)

  • 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_jaccard is 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 harness

Write-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-framework SurfaceSpec. Two instances exist (OPENCODE_SURFACE, CODEX_SURFACE), and all logic in tree.py is 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.json on 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:, a name: 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

Was this useful?
Make a page like this. Free.Notes and diagrams side by side, written by you or by Claude Code and Codex. Publish it in one click.
Make your own, free