Imported from yaniv-golan/cowork-harness (
AGENTS.md). Install upstream withnpx skills add yaniv-golan/cowork-harness. Copyright stays with the author.
Working in this repo — cowork-harness
Canonical agent-instructions file. Read this before changing code. (Claude Code also reads a private local
CLAUDE.mdoverlay when present; this file is the shared source of truth.)
A test harness that drives the real staged Claude Code agent — bind-mounted from the user's Claude
Desktop install at run time (nothing Anthropic-owned is bundled or distributed) — over the Agent SDK
stream-json control protocol, inside sandboxes of three isolation tiers + two loop-mode overlays (five
fidelity: values), to test Claude Code skills
the way Cowork runs them. It is a fidelity fixture, not the Desktop runtime.
Architecture — route a change to the right seam: AgentSession (the protocol, src/agent/session.ts)
→ Decider (policy, src/decide/) → Run (turn loop + RunRecord, src/run/run.ts). executeScenario
(src/run/execute.ts) is the library API; src/cli.ts is a thin wrapper over it. Don't put policy in the
protocol layer or run-loop bookkeeping in the CLI.
Build & gates
npm run ci(typecheck + build + test) is THE local gate before claiming done. It does not includenpm run format:check— run that separately. (CI Stage 1 runs these steps individually rather than vianpm run ci; see CONTRIBUTING.md.)- Tests are token-free & spawn-free wherever possible (
cli-jsonuses usage-errors + cassette replay). Don't add a test that needs a live model or Docker to the default suite; that's thepytest -m cowork/npm run test:livelane. Python fast lane (frompython/):pytest -m 'not cowork'. - CLI binary
cowork-harness; env varsCOWORK_HARNESS_*(+COWORK_AGENT_BINARY/COWORK_AGENT_IMAGE) — see README's Reproducibility knobs for the full env-var list. Node ≥ 22. cowork-harness syncis local-only (needs Desktop +app.asar; not on CI). The committedbaselines/*.jsonare CI's source of truth — never hand-edit release facts into source; they come fromsync(seedocs/maintenance.md).cowork-harness lintexit 127 is a hard failure (python3 not installed — PyYAML is bundled, so it's never the cause). CI scripts MUST NOT swallow this exit code — treat it as a missing gate, not a vacuous pass.
Traps — the tooling will mislead you (each one cost real agent-hours)
These are not invariants to preserve; they are places where a reasonable default assumption is wrong here, and the failure is silent. Every one below was hit by an agent working in this repo.
docs/internal/is gitignored —git status/git diffare BLIND to it. Verify work in that directory on the filesystem (mtimes, content), never via git. (An agent once concluded a completed 154-file audit "hadn't run" because git reported a clean tree.) The same applies to.envand anything else in.git/info/exclude.npm testdoes NOT typecheck. It isvitest run, which strips types.tsconfig.jsonincludes onlysrc— test files are typechecked solely bytsconfig.test.json, i.e. only vianpm run typecheck(whichnpm run ciruns). Consequence: a missing import insrcdoes not failnpm test; it becomes a runtimeReferenceError, and if it lands inside acatchit becomes silently wrong data rather than a crash. After editingsrc/, runnpm run typecheck, not justnpm test.- Deriving anything that can throw belongs OUTSIDE a nearby broad
catch. Several best-effort blocks (try { readdirSync(...) } catch { /* no dir — fine */ }) will happily swallow a different exception and report an empty result. Fail-loud contracts (e.g.gitTrackedSet's deliberate throw) are defeated by being called from inside one. - Token resolution has an
<install>/.envfallback (src/cli.ts,dist/cli.js→../.env), so running the CLI from any cwd still picks up this repo's.env. Scrubbingprocess.envandcd-ing elsewhere is NOT enough to test an unauthenticated path — only a packed install (npm pack+npm i ./tgz) isolates it. (Two live auth experiments "passed" against a token the author believed was removed.) - Only two
schema/*.jsonare generated.npm run schemawritesscenario.schema.json+session.schema.json(+ the skill'sassertion-keys.json). Everything else inschema/is hand-maintained —cassette.v*.json,run-result.json,doctor.json,protocol.v1.json,critique-report.json,verify-cassettes.json. Adding a field to those means editing the TS type and the JSON by hand; nothing syncs them. Then regenerate the surface baseline (below). - Surface-baseline ordering: edit the schema →
npm run check:surface(review the diff, expect+N -0 ~0) →npm run gen:surface→check:surfaceagain (now+0 -0 ~0). Runninggen:surfacefirst hides the very diff you were supposed to review. - A
RunResultfield is a 5-call-site change.assembleRunResulthas 5 call sites acrossassemble-run-result.ts/execute.ts/cassette.ts/chat-result.ts, plus the TS type, the hand-maintainedschema/run-result.json, and 2 fixtures.CompleteRunResultmakes every key mandatory-to-supply, so a new key touches all of them. Grep the count fresh — it has changed twice. - Some types lie at the seam.
Cassette.eventsis declaredstring[]but arrivesundefinedfrom severalcomputeStalenesscallers; partially-constructed cassettes are normal in tests and in the staleness path. Guard structurally (Array.isArray) rather than trusting the declaration. - Guards cover machine-readable structure, not prose.
surface-contractsnapshotsschemas/action/env;skill-docs-synccovers the assertion-key catalog and top-level cassette fields. Nothing checks that a new CLI flag, message, behaviour, or version coupling is documented anywhere a consumer reads. If your change is one of those, the docs are on you — see the release checklist in RELEASING.md.
Invariants — do NOT break (each one cost a real bug)
Full index (enforcement + test anchors for every invariant, including the CI-grep-only ones not repeated below): docs/invariants.md.
- AskUserQuestion answer shape.
serializeDecision(src/agent/session.ts) MUST emitupdatedInput: { questions, answers }— never{ answers }alone. The in-VM binary's handler doesquestions.map(...); droppingquestionsthrowsq.map, the answer never reaches the model, and gate-steering silently no-ops (the O7 bug). ELF-verified; a regression test pins it. - "profile" is retired vocabulary. Synced release ground truth =
PlatformBaseline(baseline:/baselines/); authored setup =SessionConfig(session:/sessions/). Theprofile:scenario key is retired vocabulary — the alias is gone and it is now rejected as an unknown key — do not reintroduce the term. - A new assertion must pick its replay class. Content assertions (read only
ctx.transcript/ the record) go in one ofsrc/run/cassette.ts's exported buckets —ALWAYS_CONTENT_KEYS,QUESTION_GATE_KEYS, orMANIFEST_KEYS— so they run on the token-freereplayPR gate; filesystem / egress assertions go inLIVE_ONLY_KEYSinstead, so they only run on live (non-replay) gates. These four exported constants are the single source of truth; the README's "what replay checks" prose just describes them. The wrong bucket is a silent no-op in CI. replayconsumescontrolOutand re-serializes viaserializeDecisionto guard the AskUserQuestion answer shape (O7) on the token-free lane. A new decision kind must extend BOTHserializeDecisionANDdeserializeDecision(declared inverses insrc/agent/session.ts) — they must not drift.evaluate()(src/assert.ts) is synchronous on purpose. No model-call / LLM-judge assertion without an explicit async-refactor decision — it would also break determinism and the replay lane.- Answer paths are orthogonal — scripted (
--answer/--answer-policy),--decider-llm,--decider-cmd(any spawned shell helper — a Pythonserve_decideradapter is one option),--decider-dir(+gates/answer+ a Monitor), and policiesfail | first | prompt. The LLM decider has two spellings —--decider-llmon the CLI andon_unanswered: llmin scenario YAML (same mechanism); the bare--on-unanswered llmCLI flag is rejected (redirects to--decider-llm) to keep deciders in the--decider-*family. Don't reintroduce overlap (the legacy stdio channel was deliberately removed).
Parallel sessions — one worktree each
Two agents in ONE checkout share a HEAD: every git checkout moves it for both. This fails silently.
Nothing errors — your rebase just gets harder, because the base moved. Measured in one session: main
advanced under an in-flight rebase three times (3a2f994 → bc394b9 → 75bc440), producing one real
CHANGELOG.md conflict and a --contains false negative that nearly justified a reset --hard over
commits that looked orphaned but weren't.
The convention:
- The primary checkout stays on
mainand is the integration point. Do no feature work there. - Each session gets its own worktree:
git worktree add .worktrees/<name> -b <branch>..worktrees/is already gitignored (.gitignore) and excluded from test discovery (vitest.config.ts). - Land with
git merge --ff-only <branch>, run IN the primary. Rebase your branch ontomainfirst so the merge is a strict fast-forward — no merge commit, no rebase during the merge, nothing to mis-resolve.
Do NOT use the ref-update trick (git fetch . <branch>:main) under this convention. It works only
while the target branch is checked out NOWHERE, and here the primary permanently holds main:
fatal: refusing to fetch into branch 'refs/heads/main' checked out at '…/cowork-harness'
(Both paths verified: the fetch succeeds into an unchecked-out branch and fails the moment a worktree
holds it; merge --ff-only succeeds in the holding worktree.) The ref-update is still the right tool for
the narrow case of advancing a branch nobody has checked out — it just cannot be the standard path.
Why the split is the real fix: a ref-update protects the other session's HEAD but does nothing for
main itself, so the base can still move under an in-flight rebase. Separate worktrees fix that half.
Two one-time costs per worktree, both benign: no ./.env (use --dotenv <primary>/.env — doctor
detects this and prints the remedy, see docs/gotchas.md), and no node_modules — run npm ci.
Do NOT symlink node_modules from the primary to skip that install. .gitignore's first line is
node_modules/ — with a trailing slash, so it matches a directory only. A symlink is not a directory to
git, so it shows up as ?? node_modules and breaks every clean-tree gate, npm run preflight included.
Verified both ways: a real node_modules/ directory is correctly ignored; a symlink is not.
Structural safety, not just etiquette: git refuses to check out main in a second worktree
(fatal: 'main' is already used by worktree at …), so the integration branch cannot be double-held by
accident.
Advisory design — the rules an emitted note/warning must satisfy
Added after 1.11.0 shipped an advisory that violated all three in the same release it fixed another one.
- Actionable by construction, or aggregated. An advisory that fires on every item is noise. If the
tool cannot tell the applicable case from the inapplicable one, it must aggregate to one line per run
(
N/M cassettes — <reason>), not repeat a constant string per item. Precedent: the skill-hash hint (once per process) and the staleness notes (one summary per kind,cmdReplay). - Severity tracks ACTIONABILITY, not novelty.
notesare non-gating by construction ⇒::notice::.findingsgate ⇒::warning::/failure. Getting this backwards makes a self-described-harmless line outrank the actionable one beside it on a CI annotation surface — which is exactly what shipped in 1.11.0, against a comment that claimed otherwise. - "harmless otherwise" in an advisory's own text is a DESIGN SMELL. It concedes the tool cannot distinguish the two cases. Either teach it to, or aggregate. Do not ship the concession.
Corollary: warn() (src/io.ts) auto-prefixes ::warning:: for an unprefixed message. If you mean
::notice::, say so explicitly — a comment claiming "plain-info" does not make it so.
Ethos — decide by these
- Binary-verify, don't infer. Anything mirroring Cowork (spawn env, egress allowlist, GrowthBook gates,
the AskUserQuestion shape) is verified against the in-VM ELF /
app.asarand cited in the change. Pin the gate value / the exact string; don't guess from behavior. - Determinism is the value of
run. Scripted answers +fail;runrejectsprompt; LLM / in-band answering flags the runnonDeterministic. Don't add non-determinism to therunpath casually. - "✓ success ≠ correct" — no silent false-greens. Anything that can silently no-op (skip on replay,
default-answer a gate, an empty allowlist from a failed
sync) MUST be loud about it. This is the project's core principle.
Pointers
- Reference, don't duplicate here:
SPEC.md(the authoritative contract),docs/{scenario,session,boundary, maintenance}.md,DESIGN.md. - Authoring scenario/session YAML: the JSON Schemas in
schema/describe every field.