harn run
harn run <script> [--adapter <name>] [--resume-from <run-id>] [--subscription-only] [--allow-api-billing] [--policy <file>] [--isolation <mode>] [--workspace-root <dir>] [--approval-to <address>] [--max-agents <n>] [--concurrency <n>] [--cwd <dir>] [--json]harn run resume <run-id> [--json]harn run workspace <run-id> [--json]harn run workspaces [--json]harn run integration prepare <run-id> --policy <file> [--reviewer <name>] [--reason <text>] [--target-root <dir>] [--accept-unknown <code>] [--approval-to <address>] [--json]harn run integration apply <run-id> --yes [--json]harn run cleanup <run-id> --yes [--json]harn run reclaim <run-id> --yes [--discard] [--json]harn approval list [--status pending|approved|denied] [--json]harn approval show <approval-id> [--json]harn approval approve|deny <approval-id> [--actor <name>] [--reason <text>] [--json]harn run proof <run-id> [--json]A workflow is a small throwaway JS script with staged fan-out: the script (deterministic code), not any model, decides what runs next, and the run always terminates when the script returns. Subagents spawn as headless adapter-CLI subprocesses and are born coordination-registered: they heartbeat, emit canonical events, and stay visible to peers, so a workflow child editing a file is subject to the same claim/guard protections as any other agent. Design record: decision 0015.
Spawn adapters: claude-code (default), codex (codex exec --output-last-message), and cursor (cursor-agent -p --output-format json --trust, since headless cursor refuses untrusted workspaces without the flag).
All three are live-verified end-to-end (codex against codex-cli 0.144.5,
cursor against cursor-agent 2026.07.16). A missing adapter
CLI fails loud with the vendor’s install one-liner and login command in the
error, and harn doctor reports all three spawn targets up front
(installed? authenticated? how will children bill?). Select the run default
with --adapter, or per agent via opts.adapter. Mixed-adapter workflows
are legal (triage on one CLI, deep work on another). Codex and cursor expose
no per-run cost or max-turns controls, so costUsd is unreported and
maxTurns is ignored on those adapters.
Auth: children ride the logged-in adapter session
Section titled “Auth: children ride the logged-in adapter session”The engine never handles credentials. Each child is the plain adapter CLI, so
it authenticates exactly the way that CLI already does on the machine:
claude uses the OAuth login in ~/.claude (a subscription seat), codex
uses its ChatGPT login, cursor-agent uses cursor-agent login. A
subscription login is the default and preferred path. No per-token API
billing is required or implied by running workflows.
The child-env scrub deletes session variables (CLAUDE*, CODEX*,
CURSOR*), not auth: credential files and keychains are untouched
(CURSOR_API_KEY is carved out of the CURSOR* scrub because it’s a credential,
not session state). The one way to end up on API billing is an API-key
variable in the parent shell (ANTHROPIC_API_KEY, OPENAI_API_KEY,
CURSOR_API_KEY): adapter CLIs prefer a key over a stored login when both
are present.
Billing safeguards
Section titled “Billing safeguards”The engine probes each adapter’s billing state on its first spawn of the
run (transcripted as billing.probe, echoed as a [billing] line, and reported
per adapter in the RunReport and web view):
subscription: no key exported; children ride the stored login.api-key: key present but no stored login detected, a deliberate key-only host (e.g. CI). Proceeds with a printed notice.api-key-override: key present AND a stored login exists, so the key is silently shadowing subsidized auth, almost always by accident. The engine refuses to spawn unless you pass--allow-api-billing.
--subscription-only goes further: every API-key var is deleted from every
child env, so children can only authenticate via their stored login, and a
adapter whose login is provably absent fails loud before spawning. Pin it
repo-wide with .harnery/config.jsonc. harn init stamps this pin by
default into any project without a committed workflow key, so new setups
are subscription-only out of the box:
{ "workflow": { "subscriptionOnly": true } }HARNERY_WORKFLOW_SUBSCRIPTION_ONLY=1|0 overrides per process (the 0
escape hatch exists for a key-only CI job inside a pinned repo).
--allow-api-billing and subscription-only mode contradict each other and
are rejected as a pair.
Login detection is a heuristic over well-known credential locations (Claude
Code’s ~/.claude/.credentials.json, codex’s $CODEX_HOME/auth.json; a
codex login --api-key stored key is classified as API-key billing, not a
login). Where presence can’t be proven either way (macOS keychain storage,
cursor), the state is unknown and never hard-fails. The adapter CLI itself
is the final authority.
Two accounting consequences: the costUsd the claude adapter transcripts is the
CLI’s notional cost figure (under subscription auth it is not an invoice,
just a burn gauge), and a large fan-out consumes the subscription’s usage
allowance the same way interactive sessions do, so the agent cap and
concurrency pool are still your throttles.
Script shape
Section titled “Script shape”export const meta = { name: "review-prs", description: "triage + deep-dive open PRs", objective: "Produce an inspectable risk assessment for every routed PR", acceptance: [ { id: "tests", statement: "The focused test suite passes" }, { id: "review", statement: "The routed changes receive a final review" }, ],};
const TRIAGE = { type: "object", required: ["route", "reason"], properties: { route: { enum: ["close", "analyze", "keep"] }, reason: { type: "string" }, },};
export default async function run({ agent, parallel, stage, log, evidence }) { stage("triage"); const verdicts = await parallel( prs.map((pr) => () => agent(`Classify this PR… Return ONLY JSON {route, reason}.`, { schema: TRIAGE, model: "claude-haiku-4-5", maxTurns: 1, })), );
stage("deep-dive"); for (const [i, v] of verdicts.entries()) { if (v?.route !== "analyze") continue; // conditional routing: code decides await agent(`Risk-assess: ${prs[i]}`, { label: `deep: ${prs[i]}` }); }
evidence({ kind: "test", status: "passed", label: "Focused workflow tests", ref: "bun test src/workflows/review-prs.test.ts", acceptanceIds: ["tests"], });}Work-linked context
Section titled “Work-linked context”When the workflow runs through harn work or
harn governor, ctx.work contains the frozen assignment:
export default async function run({ work, agent, evidence }) { if (!work) throw new Error("this workflow requires durable work context");
const result = await agent( [ "Complete the durable assignment below.", `Title: ${work.title}`, `Objective: ${work.objective}`, "Acceptance:", ...work.acceptance.map((criterion) => `- ${criterion}`), ].join("\n"), { specialist: "implementer" }, );
evidence({ kind: "observation", status: "observed", label: `Specialist completed ${work.id}`, }); return result;}The shape is { schema_version, id, title, objective, acceptance }. It is
deeply frozen before the script starts, copied into the private run manifest
and terminal proof, and restored from that manifest after an approval park.
Standalone workflows and historical parked runs created before this capability
have ctx.work === undefined.
Harnery does not automatically prepend this data to child prompts. The script controls how much assignment context each specialist needs and must preserve the host’s instruction and policy hierarchy when work text came from an external source.
Attempt and retry context
Section titled “Attempt and retry context”New work-linked runs also receive frozen ctx.attempt data:
export default async function run({ work, attempt, agent }) { if (!work || !attempt) throw new Error("durable work context required");
const correction = attempt.trigger === "retry" ? [ `This is attempt ${attempt.number}.`, `Prior causes: ${attempt.prior.causes.join(", ")}`, attempt.prior.error ? `Prior error: ${attempt.prior.error}` : "", ...attempt.prior.unresolved.map( (criterion) => `${criterion.status}: ${criterion.statement}`, ), ].filter(Boolean) : [];
return agent( [ `Objective: ${work.objective}`, ...correction, "Complete the assignment and verify its acceptance criteria.", ].join("\n"), { specialist: "implementer" }, );}An initial attempt receives
{ schema_version: 1, number, trigger: "initial" }. A retry additionally
receives prior with the prior run ID, deterministic failure causes, the
bounded workflow error when present, the acceptance summary, and only
unsatisfied or unknown criteria. Causes can be workflow_error,
acceptance_unsatisfied, acceptance_unknown, or lost. lost means only
that terminal proof is absent; it does not diagnose why the run disappeared.
The engine persists the same value as attempt_context in the private manifest,
transcript, and terminal proof. Approval resume restores that original value and
does not become a retry. Standalone and historical context-free runs have
ctx.attempt === undefined.
Attempt context does not grant retry authority or add attempts. Explicit
harn work retry, frozen governor policy, and the existing work and
goal-wide ceilings still decide whether another run may start. As with
ctx.work, the workflow controls whether and how the data enters a child
prompt.
The ctx API
Section titled “The ctx API”| Property or function | Contract |
|---|---|
work |
Optional deeply frozen durable-work assignment: work ID, title, objective, and acceptance criteria. Present for new work-linked attempts; absent for standalone and legacy-resumed runs. |
attempt |
Optional deeply frozen attempt identity. A retry includes a bounded synopsis of the preceding terminal evidence; approval resume preserves the original attempt. |
agent(prompt, opts?) |
Spawn one subagent. With opts.schema the reply must parse as one JSON value and validate against the schema subset (type, oneOf, properties, required, additionalProperties, items, minItems, maxItems, minLength, maxLength, pattern, enum). Exact JSON and a whole fenced value are accepted. A adapter-added leading sentence is tolerated only when exactly one unambiguous object or array reaches the end of the reply; trailing prose, multiple candidates, malformed values, and schema mismatches still fail. Failures re-prompt with the validation errors appended, up to opts.maxAttempts (default 2), then throw. Without a schema, resolves to the raw reply text. Other opts: specialist, model, effort, maxTurns (default 25; use 1 for pure classification), timeoutMs (default 300000), label. |
parallel(thunks) |
Run thunks against the run-wide concurrency pool. A rejected thunk resolves to null (transcripted), so one bad item can’t kill the batch; filter and route. |
stage(title) |
Declare the current stage: transcript marker + progress grouping. |
log(message) |
Narrate progress (stderr + transcript). |
evidence(input) |
Attach a bounded receipt to the run and optional acceptance IDs. Returns a stable ID such as e1. Kinds: test, command, artifact, change, review, observation. Statuses: passed, failed, observed, unknown. |
authorize(input) |
Authorize one host-mediated external mutation before performing it. Input includes a short action plus optional path, network, service, and bounded target. Returns the final allow decision; denial or missing host policy throws before control returns. |
blocked(input) |
Stop the run because a human must rule. Input is a reason sentence plus the docket decision id. Never returns. See Blocking on a human. |
An evidence kind outside that set is caught before the run starts. evidence()
sits near the end of a workflow by construction, so an invalid kind used to throw
after every agent had finished and take the whole run’s work with it. Harnery now
reads the script first and refuses any literal kind it will not accept, naming
the line, before a single agent is spawned. A kind computed at runtime is still
only checked when the call executes.
effort is validated and mapped per registered adapter: Claude Code accepts
low | medium | high | xhigh | max; Codex accepts
none | minimal | low | medium | high | xhigh. Cursor does not accept a
separate effort option through Harnery because its effort syntax is embedded
inside some parameterized model ids. See harn adapter for the
executable capability declaration.
Blocking on a human
Section titled “Blocking on a human”Some work cannot be finished by any agent, however good, because the open
question belongs to a person: a product trade-off, a spend authorization, a
call about risk. ctx.blocked() is how a script says so.
export default async (ctx) => { const verdict = await ctx.agent(reviewPrompt, { schema: VERDICT }); if (verdict.needsRuling) { ctx.blocked({ reason: "which subsystem owns the cart is unsettled", decision: "who-owns-the-cart-2026-08-01-beaf", }); } // …};This is not an error path, and using it as one defeats it. A plain throw means
the work failed and a retry might do better. blocked() means the opposite:
the script was right, and the blocker is a person.
The distinction is load-bearing because it changes what happens next:
- The attempt is uncharged, so once the decision lands the item retries with its full budget. The same three classes share this treatment; see failure classes.
- The work item goes to
blockedwithnext_action: "none", so aharn governorrunning withretry_blockedcannot re-issue it. That automation exists to clear failures without a human; a correct refusal is not a failure. - The goal’s own reason names the decision rather than reporting a count of
items “needing intervention”, and
decision_blocked_workon the projection carries the work/decision pairs for anything rendering a queue.
Without a class of its own, a correct refusal and a botched attempt look identical to the engine. It re-issues the item, the next agent reaches the same correct conclusion, and the loop repeats until the budget is gone, with nobody told, because the thing it is waiting for is a person who was never asked.
Pass the decision id whenever you have one; it is what turns “something needs
a human” into a question someone can actually answer. File it first with
harn decision. Blocking without an id still stops the item, but
the operator gets prose and has to go find the question themselves.
Failure classes
Section titled “Failure classes”A failed run carries an optional class saying the failure was uninformative
about the work. All three mean the attempt is not charged against
max_attempts, because nothing was learned about whether the work is doable:
| Class | Meaning | Retry |
|---|---|---|
environment |
A precondition was missing; the run never started (the adapter CLI was absent). | Stops. Retrying an unchanged environment cannot help. |
upstream |
The vendor was reached and refused (5xx, 429, circuit open). | Stays available, bounded by max_uncharged_attempts. |
decision |
The script called blocked(): a human must rule. |
Stops. Only a ruling clears it. |
Absent class ⇒ an ordinary work failure, charged and retryable as before.
Specialist profiles
Section titled “Specialist profiles”An embedding host may provide EngineOpts.specialists, and a
harn governor freezes those profiles into its durable goal
intent. A workflow selects one with agent(prompt, { specialist: "reviewer" }).
The profile contributes bounded instructions plus optional adapter, model,
effort, turn, timeout, and schema-retry defaults; options on the individual
agent call override those defaults.
The engine prepends the frozen role instructions to the assignment and records the specialist ID in the transcript and proof packet. The complete profile map is also frozen in the workflow run manifest. Approval resume therefore cannot silently pick up changed role instructions or model settings. Specialist ID, resolved options, and wrapped prompt participate in resume-cache identity.
Bounds
Section titled “Bounds”--max-agents(default 50): total-agent ceiling for the run. The cap failing loud is the runaway backstop; there is no recursive spawning path, because subagents are leaf processes.--concurrency(default 4): one shared slot pool for every spawn in the run, so nestingparallel()inside loops can’t exceed it.- Policy receipts are capped at 50 per run. Reaching the cap fails closed before another protected action.
Host policy
Section titled “Host policy”--policy <file> applies a host-owned JSON/JSONC policy immediately before
every uncached agent dispatch. The policy can constrain run cost, unknown
pricing, adapter, model, working path, network state, and the host-declared
isolation mode. It is not part of workflow metadata, so the script and child
prompts cannot weaken it.
Rules return allow, ask, or deny. The standalone CLI parks on ask and
writes a durable request under .harnery/approvals/; no protected operation
runs while it is pending. Library callers remain fail-closed by default and
must select EngineOpts.approvalMode = "park" deliberately. An embedding host
can still supply EngineOpts.resolvePolicyAsk for immediate decisions. Use
harn policy to validate a document before a run.
--isolation declares the requested boundary: shared, worktree, sandbox,
or remote. The standalone CLI can create the built-in local Git boundary
when worktree is paired with an explicit --workspace-root. Other boundary
types still require an embedding host provider. Ordinary CLI adapter
subprocesses inherit host networking, so the CLI declares network enabled and
a network: deny policy blocks dispatch.
Workflow code may use ctx.authorize() before an external mutation:
await authorize({ action: "publish release", network: true, service: "package registry", target: "https://registry.example.test/package",});// Perform the operation only after authorization returns.This seam protects host-mediated operations. It does not intercept arbitrary
tools invoked inside an opaque adapter subprocess. Adapter-native policy
mapping remains a separate capability claim in harn adapter show.
Cursor workflow children run in non-interactive print mode with workspace trust
and command execution enabled. Print mode has no operator prompt channel, so a
child without command authorization can edit through its patch tool but cannot
run tests or create a commit. This does not make Cursor’s inner tools
policy-aware: harn adapter show cursor still reports policy mapping as
unsupported. Use a provider with a stronger filesystem boundary when prompt
instructions alone are not sufficient isolation.
Isolated worktrees
Section titled “Isolated worktrees”The local provider needs an explicit writable parent:
harn run ./review.mjs \ --isolation worktree \ --workspace-root ../workflow-workspaces \ --policy ./workflow-policy.jsonHarnery probes Git and filesystem capabilities before allocation. A supported run receives a dedicated branch and worktree. The immutable run manifest binds that workspace to the exact run or durable-work attempt. Children start in the isolated active root, and approval resume reconstructs the same built-in provider from the frozen binding.
If a non-shared mode has no provider, Harnery records a compatibility decision instead of pretending isolation happened. The run uses the shared checkout only when policy permits it. Once a binding exists, resume cannot fall back to shared execution.
Inspect one run or list all isolated and compatibility decisions:
harn run workspace wf-2026-07-24T02-10-00-000Z-a1b2c3harn run workspace wf-2026-07-24T02-10-00-000Z-a1b2c3 --jsonharn run workspacesThe status reader validates the manifest, proof, provider event chain, integration authority, cleanup attempts, and receipts before projecting a lifecycle. It reports allocation, verification, integration, conflicts, cleanup, and resource state. Corrupt or contradictory evidence returns an explicit invalid inspection.
Finish a verified standalone run in explicit phases:
harn run integration prepare <run-id> \ --policy ./integration-policy.json \ --reviewer sam \ --reason "reviewed the exact proof and diff" \ --accept-unknown network_not_attested
harn run integration apply <run-id> --yesharn run cleanup <run-id> --yesPreparation previews the fast-forward and writes review, plan, and policy
authority. It does not update the target branch. A work-linked run gets its
review from exact durable work acceptance, so --reviewer is needed only for a
standalone run. Repeat --accept-unknown for each verification fact the
reviewer accepts.
If the policy returns ASK, preparation creates a durable approval and parks
without applying Git changes or reporting a generic workflow failure. Text mode
prints the run, plan, and approval IDs plus a copyable
workflow approvals approve command. JSON mode emits a stable parked envelope
with status, runId, planId, and approvalId. Resolve the approval, then
rerun the same prepare command. The exact plan is reused and authorized. Apply
requires --yes and rereads the plan, proof, review, policy decision, approval,
target identity, and attempt chain before Git changes. Divergence, target
movement, dirty state, or a Git operation in progress blocks the apply. Harnery
does not resolve conflicts.
Cleanup also requires --yes. It removes only resources covered by the frozen
binding and cleanup intent. A clean unintegrated branch that is not reachable
from the target is preserved, as is dirty or ambiguous work. The CLI supports
the built-in local Git provider. Embedding hosts use prepareIntegration(),
applyIntegration(), and cleanupWorkspace() for other providers.
Reclaiming a preserved workspace
Section titled “Reclaiming a preserved workspace”Preserving dirty work is correct, but a preserved workspace needs a way out.
Cleanup will keep finding the same dirty tree and preserving it again, so
reclaim resolves the work first and then releases
(ADR 0042):
harn run reclaim <run-id> --yes # salvage the work, then releaseharn run reclaim <run-id> --discard --yes # throw the work away, then releaseSalvage commits the uncommitted work to a durable harnery/salvage/<run-id>
branch, so it stays recoverable by name after the worktree directory is gone.
Discard is never the default.
Neither mode removes anything itself. Each makes the working tree clean and then
hands off to the ordinary cleanup path, so cleanup stays the only thing that
removes a worktree. A workspace whose directory is already gone reports
already_gone instead of failing.
harn run workspace <run-id> lists the dirty paths, so the choice between
salvaging and discarding can be made without leaving Harnery.
Resume
Section titled “Resume”--resume-from <run-id> reuses completed agent results from a prior run’s
transcript: an agent() call whose identity (stage, adapter, model, effort, maxTurns,
schema, and the ORIGINAL prompt) matches a transcripted agent.end returns the
recorded result instantly without spawning (transcripted as agent.cached).
Changed or previously-failed calls re-run live. Same script + same inputs →
100% cache, $0. A typo’d run id fails loud rather than silently re-running
everything.
Durable approval parking
Section titled “Durable approval parking”A CLI run that reaches policy ask writes run.parked, prints its approval
ID, and exits without run.end or a terminal proof. Inspect and resolve it:
harn approval list --status pendingharn approval show apr-0123456789abcdef0123harn approval approve apr-0123456789abcdef0123 \ --actor ryan --reason "reviewed the release target"harn run resume wf-2026-07-21T12-00-00-000Z-a1b2c3Requests are immutable private records. Resolution creates a separate exclusive decision record: the first verdict wins, the same-verdict retry is idempotent, and a conflicting retry is refused. Approval does not auto-spawn work. Resume must be invoked explicitly, and an exclusive resume lease refuses concurrent attempts for the same run.
workflow resume reuses the same run ID and transcript. It verifies the frozen
script SHA-256, reconstructs completed agent results and committed cost, and
reruns the deterministic script from its entry point. Engine-managed
agent() calls return cached results and the matching approval decision is
replayed. Plain JavaScript side effects executed directly before the park run
again; keep them idempotent or put protected effects behind authorize().
Context-cost heads-up
Section titled “Context-cost heads-up”Children spawn in --cwd (default: the coord root) and load that directory’s
repo-instructions file (CLAUDE.md / AGENTS.md) into their system prompt,
a per-child cache-write a fan-out multiplies. The engine prints the estimate
up front ([context] each child cache-writes ~51K tokens …, bytes/4
heuristic) and reports it as contextTokensPerChildEstimate in the
RunReport. For mechanical stages that don’t need repo instructions, point
--cwd at a directory without one.
Output
Section titled “Output”Human mode prints a run summary (agents, cached count, cost, duration, transcript
path, proof path, acceptance summary, result). --json emits the full
RunReport. Every run transcripts to
.harnery/workflows/<run-id>/transcript.jsonl: run.start, stage.start,
agent.start/end (cost, duration, child session id, result), agent.cached,
agent.schema_retry, evidence.recorded, run.end. New transcript lines carry
schema_version and run_id; older transcripts remain resumable.
Durable approval runs also use approval.requested, approval.resolved,
approval.consumed, run.parked, and run.resume.
Proof packets
Section titled “Proof packets”Every terminal run, including a failed one, writes
.harnery/workflows/<run-id>/proof.json. The packet is written atomically with
mode 0600 and contains:
- the objective, terminal status, timestamps, and acceptance rollup;
- the exact bounded work context for work-linked runs;
- typed evidence declared through
ctx.evidence(); - agent outcomes, attempts, duration, cost, and session ID when reported;
- SHA-256 digests and byte counts for agent and workflow results, not their raw text;
- engine-observed repository snapshots before and after the run;
- adapter tool-evidence coverage from the adapter capability registry;
- explicit unknowns for missing capability claims, cost, session IDs, and incomplete repository drift;
- a SHA-256 digest of the final transcript.
- the normalized host policy, its digest, host isolation/network declarations, and bounded initial/final decision receipts when policy was configured.
Acceptance is deliberately simple. Any attached failed evidence makes a
criterion unsatisfied. At least one passed item and no failure makes it
satisfied. observed, unknown, or no attached evidence leaves it unknown.
An item recorded through ctx.evidence() has source workflow: Harnery stores
the declaration but does not claim it independently ran the command or test.
Repository snapshots and packet integrity are marked separately as
engine-observed.
Inspect a packet with harn run proof <run-id>. Human mode renders the
status, criteria, evidence count, repository drift, unknowns, and transcript
digest. --json emits the stored schema unchanged. Failed workflow run
commands include the proof path in their error output.
Proof packets are bounded to 50 criteria, 200 evidence records, and 512 KiB. Labels, summaries, and references have field-level limits. Unknown acceptance IDs and duplicate criterion IDs fail loud. The packet does not run a secret redactor; do not put credentials into objectives, evidence summaries, or references. The detailed transcript continues to retain full agent results for resume compatibility.
The programmatic surface is exported from harnery/core/workflow, including
the engine, proof reader and renderer, workspace provider, integration and
cleanup functions, readWorkflowWorkspaceStatus(),
inspectWorkflowWorkspace(), listWorkflowWorkspaceInspections(), schema
types, and WORKFLOW_PROOF_SCHEMA_VERSION.
Web view
Section titled “Web view”The dashboard (harn web up) ships a /workflows page: transcript-driven list
of runs (status, agents, cached count, cost, stage chain) and a per-run detail
view rendering the stages → agents tree with per-agent adapter, attempts,
duration, and cost. Parked runs have a distinct awaiting-approval status and
show the durable approval ID and inspection command. When a terminal proof packet exists, the detail page also
shows the objective, acceptance criteria, declared evidence, repository drift,
host-policy decisions, and explicit unknowns. The run list also shows the
workspace lifecycle. Detail pages separate allocation, verification,
integration, cleanup, and repository conflicts. Invalid durable workspace
evidence appears as an error instead of a healthy badge. Transcript-driven means a
run stays inspectable while live and after the orchestrator exits, with no
terminal paging limits.
Workflow children and the end-of-turn ritual
Section titled “Workflow children and the end-of-turn ritual”Children run with the host’s hooks on (that’s what makes them
coordination-visible) but with HARNERY_WORKFLOW_CHILD=1, which the stop-hook
rule honors as an exemption (stop-hook.workflow_child): the human-facing
end-of-turn ritual doesn’t apply to a headless child reporting to a transcript.
Everything else (heartbeats, canonical events, claims) behaves normally.