Optional ReadonlyhooksThe conventional standalone hooks FILE a plugin may ship instead of inline
registrations, e.g. hooks/hooks.json. A file, never a directory.
Optional because OpenCode has no such file — its .opencode/plugin is a
DIRECTORY of JS modules, and naming it here made every reader that treats
this as a file (dirname, parse, round-trip) wrong about it.
Optional ReadonlyhookThe directory a plugin keeps its EXECUTABLE HOOK SCRIPTS in (hooks) —
distinct from where the hooks are REGISTERED (hooksConventionPath,
settingsPath). Absent means the harness has no scripts directory to
scan (OpenCode's hooks are in-process code modules).
🔴 IT REPLACES TWO AD-HOC DERIVATIONS AND ONE HAND-WRITTEN LIST, which is
why it is a field rather than something computed at each site. The list was
intraRefDirs, written out per layout and therefore free to disagree with
the surfaces beside it (on opencode it did, dropping the skill dir). The
derivations were hooksConventionPath.split("/")[0], copied into scan.ts
and scan-files.ts — which reads hooks from hooks/hooks.json but
.codex from .codex/hooks.json, i.e. it did not name a scripts directory
at all for Codex. Both are now executableSourceDirs.
ReadonlyinstructionTop-level instruction file, e.g. CLAUDE.md.
ReadonlymanifestPlugin manifest, e.g. .claude-plugin/plugin.json.
ReadonlymcpStandalone MCP config file, e.g. .mcp.json.
ReadonlymcpManifest key declaring MCP servers, e.g. mcpServers.
ReadonlynameStable identifier, e.g. "claude-code".
Optional ReadonlyoutputWhere this harness keeps output styles and how it switches one on (absent =
this harness has none). Not a SurfaceKind: like a rules file, a
style is read into the session, not invoked. See output-style.ts.
ReadonlypluginEnv token expanded to the plugin's absolute root in hook commands.
Optional ReadonlyprojectTokens that root a path at the PROJECT being scanned, braced form
(${CLAUDE_PROJECT_DIR}); the unbraced spelling is derived. Optional — a
harness with no such variable omits it.
🔴 SEPARATE FROM pluginRootToken, and its absence made a whole
surface kind invisible. hookScripts stripped only the plugin token, so a
project's own "$CLAUDE_PROJECT_DIR/.claude/hooks/x.sh" — Claude Code's
DOCUMENTED spelling for a project hook, because hooks do not run with a
stable cwd — failed existsSync and was dropped. MEASURED on a real
consumer repo: audit listed sixteen hooks and the SURFACE list held zero.
Optional ReadonlyrulesPath-scoped RULES dir, holding flat <dir>/<name>.md, e.g. rules
(absent = this harness has no such layer; "" is refused by conformance).
Claude Code loads .claude/rules/*.md as project instructions, scoped by a
paths: frontmatter key. It is an INSTRUCTION surface — often where a
team's hardest policies actually live — and until now no layout named it, so
frontmatter-valid and the rule map simply never saw those files. An
adopter reported five such files arriving in a session labelled "project
instructions" while lint did not mention them at all (#175.3).
Not a SurfaceKind: an instruction is read, not invoked — see the docblock there for the count behind that.
ReadonlysettingsHow manifestPath and settingsPath are ENCODED — a codec,
not a format name. It knows bytes-to-value and nothing about what the value
means; the hooks-entry SHAPE lives on HookProtocol.registration.
🔴 IT USED TO BE settingsFormat: "json" | "toml", and nine call sites
branched on it. The type had exactly two inhabitants and both were ours, so
a third-party adapter whose settings are YAML could declare nothing the
core would honour — and the conformance kit re-checked the enum, which is
the tell: a data field whose legal values the core already knows is not
data, it is a hidden switch. Six of the nine branches were the encoding
and are now settings.parse / settings.render; three were the entry
shape and moved to the port that owns shapes.
Optional ReadonlysettingsThe WORD a per-machine settings sibling inserts before the extension, e.g.
local → .claude/settings.local.json (absent = this harness has no
per-machine settings layer that we have OBSERVED).
🔴 THE CORE USED TO SYNTHESIZE THIS FOR EVERYBODY, and the function it used
says on its own docblock why that cannot be right: "Both vendors name a
per-machine file by inserting a word before the extension, and they choose
DIFFERENT words — Claude Code local, Codex override — so the word is the
argument and the spelling is not." settingsSourcePaths passed "local"
unconditionally three lines below it, so a Codex repository holding
.codex/config.local.toml had that file read as a real settings layer, and
a project_doc_fallback_filenames in it could name an instruction file we
then scored — out of a file the harness never opens. opencode.local.json
the same.
Absence is the honest default: only the adapter knows whether its vendor has such a layer, and we model what has been observed rather than what is plausible by analogy. Every other siblingNamed caller already lives in an adapter for exactly this reason.
ReadonlysettingsRepo settings carrying hooks, e.g. .claude/settings.json or .codex/config.toml.
ReadonlysurfacesWhere each model surface lives — see SurfaceDirs. At least one kind
is required (conformance); a kind this harness does not have is an absent
key, never "".
Optional ReadonlyuserThe dot-directory a plain END USER keeps the same surfaces under, when the
harness has such a second home (.claude → .claude/skills). When set,
every surface is read from BOTH <surface> and <userSurfaceRoot>/<surface>,
and this is ALSO the prefix a relocated scope is keyed under.
🔴 IT USED TO BE TWO FIELDS. materializeRoot sat beside this one and was
EQUAL to it in all three shipped layouts (.claude/.claude, ""/absent,
""/absent) while having no defined meaning when they differed — the
scope-key guard existed precisely to catch a layout that named .claude as
both its materialize root and a second scope's base, and with one field that
state cannot be written. Absent means the surfaces have exactly one home and
file-map keys equal on-disk paths; see materializePrefix.
Which of the instruction-shaped files the DOMAIN enumerated this harness
loads at a repo-root session, in load order — and for every one it does not
load, WHY. Pure: a function of files alone, with no root, no node:
module and no way to name a path the domain did not open.
🔴 IT REPLACED A GLOB MINI-LANGUAGE AND ITS PRIVATE INTERPRETER.
HarnessDialect.instructionBudget.alwaysLoaded was an array of globs an
ADAPTER wrote and the CORE walked the repository to expand, so shipping
"**/AGENTS.md" made vigiles read every directory of somebody else's
project. See ./instruction-chain.ts for the three defects that had, and
for why none of them is expressible as a better glob.
instructionFile still names the PRIMARY root file — the one
compile writes, init scaffolds and detect scores. Whether it is in the
loaded chain, and what else is, is this method's answer and never that
field's.
Where one harness keeps the files vigiles reads: its instruction file, its model surfaces, its hook registrations and settings, plus the env token expanded to the plugin root. The loader reads all of it from this descriptor rather than hard-coding one harness's conventions, which is what lets a second harness be a VALUE passed to the same loader instead of a fork of it.
🔴 EVERY FIELD HOLDS ITS FACT ONCE. Four of the fields this interface used to have were second copies of a fact another field already held —
surfaceDirsbesideskillDir/agentDir/commandDir,intraRefDirsbeside both, andmaterializeRootbesideuserSurfaceRoot— and nothing related the copies, so a layout could disagree with itself and no check would see it. One did:opencodeLayoutnamed a skill dir thatsurfaceDirsomitted, and OpenCode's skills were read by nothing. The copies are nowsurfaceDirs(),executableSourceDirs()andmaterializePrefix()— functions of what is left, so there is no second place to disagree with.