cowork-harness
Version updated for https://github.com/yaniv-golan/cowork-harness to version v1.7.0.
- This action is used across all versions by 0 repositories.
Action Type
This is a Composite action.
Go to the GitHub Marketplace to find the latest changes.
Action Summary
This GitHub Action creates a headless, scriptable, CI-ready test harness for testing Claude Cowork skills. It reproduces Claude Cowork’s observable runtime contract closely enough to test the skills you write across multiple scenarios and in CI environments, without using the locked Desktop app. The action supports various fidelity tiers including replay, lint, container, hostloop, and microvm, each requiring different prerequisites such as a running agent, a Claude token, and a Docker or Lima runtime.
What’s Changed
Added
migrate-run-dir— convert pre-layout run dirs to the per-turnturns/<N>/layout, in place. A run dir written before the per-turn layout keepsresult.json/run.jsonl/trace.json/resources.jsonlat its root. Once the legacy read layer is removed, those dirs become unreadable toverify-run/diff/inspect/stats; this command converts them so the history survives the change instead of having to be re-run.Dry-run by default —
--writeapplies, and--scenario <name>scopes the run to a single scenario so a rollout can be staged: migrate one, verify it, then do the rest. It renames rather than copies, so file mtimes (the recency signalstatsandstatus --latest-forrank by) survive untouched, and it restores directory mtimes afterwards. An interrupted run records a journal outside the run dir and is finished by re-running the command. Anything it cannot resolve unambiguously — a root artifact that is neither a duplicate nor placeable, telemetry whose turn boundary cannot be dated or that spans more than two turns or whose samples would land in a turn no transcript or result evidences, a dir with no transcript at all — is refused and named. The one inference it makes is positional: an EMPTY file has no content to attribute, so it follows its position to an EVIDENCED turn (its own, by name or by rootArtifactTurn when a root transcript or result exists to move there) — never one it would mint. The same never-mint rule holds on the content path: a strayresources.turn-N.jsonlcannot manufactureturns/N/whether it is empty or carries samples, and a fully-archived dir’s trailing telemetry cannot manufacture the next turn out of arithmetic alone. Exit1when anything was refused, so a CI caller sees unfinished work. After a--writethat migrated or recovered anything, it prints a reminder to rebuild the index (cowork-harness stats --reindex), since the index keys a row’s timestamp offresult.json’s mtime and the files have moved.pruneskips scenarios with a migration in flight. Between an interrupted migration and its recovery a run dir’s mtime reflects the migration, not the run — andpruneranks keep-slots by that mtime, so it could evict a newer run in favour of a half-migrated older one. It now defers those scenarios and says so.critiquesurfaces the GRADED turn’soutcomeandskillHashin its own report (gradedOutcome/gradedSkillHashin JSON, and in the text header), and writes the graded result under the stable nameresult.graded.json.critiqueruns two turns into one run directory, so after the resumeresult.jsonis the reflection turn’s and the graded turn is archived asresult.turn-1.json— the correct file to read was the lower number, the opposite of every other multi-run convention. A harvester readingresult.jsonsilently ingested the reflection turn’s numbers: valid-looking, wrong, and unsignalled. Reported by a consumer building exactly that harvester; a documentation-only fix would have helped only readers who already knew to look.exec_infra_errorverdict signal (WARN) — a containerexecthat failed for infrastructure reasons, as distinct from the fail-severityinfra_error(a supervising process died). One failed command no longer contaminates a whole run’s evidence.RunResult.infraErrors[].sourceis now an enum —hostloop-sidecar/hostloop-exec/egress-sidecar. The origin is what drives severity, and it is carried through the frozen cassette so replay reaches the same verdict as the live run.Capability use-scan health — an unreadable or partially unparseable
events.jsonlis now reported as a degraded scan instead of being indistinguishable from a complete scan that found nothing.Every
critiquelimitation is now tagged with WHY it exists, not just what it is —structural(permanent, architect around it),unverified(unproven, not known-impossible),deliberate(a design choice),not-built(simply absent). The distinction a reader needs is rarely “what can’t it do” but “should I design around this forever, or wait for it?” Container-tier-only isunverified: the resume-continuity proof was run against the container tier’s Linux ELF, and hostloop runs a different (native) agent binary, so the proof does not transfer — nothing suggests hostloop would fail, nobody has run it. Lifting the pin needs BOTH a live resume-continuity proof at hostloop against its native binary AND the follow-on work that proof unblocks (unpinning three hard-coded container sites, stamping the tier on the session manifest so a cross-tier resume fails loud, and plumbing host-write consent) — evidence alone is not sufficient. A consumer read that pin as permanent and built a second test lane around it. The tags appear incritique --helpand docs/critique.md, generated from one source.critique --help’s KNOWN LIMITATIONS block is generated from that source, and CI asserts the shipped binary’s output, the docs bullets, and their tags all agree.
Changed
⚠️ BREAKING: per-turn run-directory layout, single shape — the root-level
result.jsoncompatibility copy is REMOVED. A run directory that holds several turns (any--resume, and everycritique) writes each turn’sresult.json,run.jsonl,trace.jsonandresources.jsonlintoturns/<N>/, once, under its final name — nothing is renamed or overwritten as later turns arrive.chatnow goes through the same layout too (alwaysturns/1/— achatsession mints a fresh dir per invocation and never resumes).<outDir>/result.jsonno longer exists — there is no root compat copy of any per-turn artifact, on ANY run dir. Readturns/<N>/result.jsondirectly (turns/1/for a single-turn run), or — forcritique— the unchangedresult.graded.json/trace.graded.jsonrole aliases. Cumulative streams (events.jsonl,timeline.jsonl) and session state are unchanged, socritique’s byte-offset turn-isolation proof and cassette capture are unaffected.Two prior shapes are now REFUSED, loudly, by name, instead of being silently misread:
- a pre-layout run dir (written before
turns/<N>/existed: rootresult.json/run.jsonl, or a name-mangledresult.turn-<N>.jsonarchive, noturns/); - a mixed run dir (a pre-layout dir resumed under CURRENT code before this release —
turns/present and a stray root/archived file).
verify-run,inspect,scaffold,diff,status --latest-for, and a resumed--session-idall refuse these with a message naming the shape found and pointing attrace <dir>— which still works fully, since every one of its views derives fromevents.jsonl, which never moves.stats --reindexcounts them as skipped and names the remedy rather than dropping them from the index quietly.Migration:
cowork-harness migrate-run-dirconverts a pre-layout dir in place (dry-run by default), preserving the file timestampsstatsandstatus --latest-forrank by.diffandstatus --latest-forare called out because their pre-refusal behaviour was the dangerous kind:diffreported two genuinely different runs asidenticaland exited 0, andstatus --latest-forcould select a different run than the newest and report its verdict — a CI script reading.verdict.passgot a green light for a red run.Previously the latest turn lived at the root while earlier ones were name-mangled archives, so a file’s name depended on whether a later turn ever happened; that shape produced a wrong-turn read, a destroyed trace, and a dropped index row — this release’s read-side (
turnArtifactPath/listTurnsinturn-layout.ts, with the oldreadTurnResultdeleted for having zero production callers) no longer has a legacy-resolving branch at all, so that class of bug is now unrepresentable rather than merely fixed. The Python SDK’s_latest_run_jsonllikewise now raises loudly on a pre-layout dir instead of silently falling back to a rootrun.jsonlthat (for any current-layout dir) is a path to nowhere.- a pre-layout run dir (written before
Platform baseline synced to Desktop 1.24012.1 (
baselines/desktop-1.24012.1.json, now whatbaseline: latestresolves to). The staged agent binary is2.1.217(native app + VM ELF, new sha256 for each). The baseline moved in two steps this release — an earlier sync to 1.24012.0 (agent2.1.215), then to 1.24012.1 — with no prompt, spawn-env, or egress-allowlist drift across either:spawn.envis byte-identical to 1.22209.3, the same 15-domain allowlist andgvisormode carry over (the effort map is the one spawn field that changed — see the sonnet-5 delta below), and thederiveSpawnEnv/checkSpawnContractFactsoracles stay green against the live asar. The substantive deltas all came from the 1.24012.0 step and carry forward unchanged:claude-sonnet-5joins the per-model effort map (low|medium|high|xhigh|max, recommendedmedium, modesauto); thecoworkRuntimeConfiggate drops itspluginsFullSyncStalenessMskey (never modeled here, inert); and the dormantautoModeOverridesAlwaysAllowsentinel fired — see below. 1.24012.1 itself adds only the agent bump: the2.1.217binary can emit the VCS SDK eventscode_change_published/vcs_state_changed(SDK floor2.1.216), which the harness surfaces as asystem_event(its existing graceful degradation of an unmodeled system event, unchanged), and the binary’s native skill-discovery enable predicate widened to three branches — still inert here, since real Cowork’s model-visible surface is the Desktop SDK-MCP discovery servers, not the native tools. The example cassettes'fingerprint.baselinetracks the new baseline.The
autoModeOverridesAlwaysAllowgate (4200321681) flipped absent → on (source: force) and was revisited as its pin intended. It stays unmodeled, deliberately: binary-verified in 1.24012.0, both call sites only override an already-existing always-allow decision — the session rule cache (approvedToolNames) and scheduled-task auto-approval — each further gated onpermissionModeandisDestructiveConnectorTool. The harness persists neither, so it already prompts wherever the gate makes Cowork prompt; enabling it moves real Cowork toward harness behavior rather than away. Revisit only if the harness gains a persistent per-tool approval cache.The staged agent (
2.1.217) enforces sub-agent fan-out caps, sodispatch_count_maxis now framed as a budget UNDER Cowork’s cap, not a reproduction of it. Because the harness spawns the real binary, a run that fans out past the agent’s caps now errors from the binary itself: a concurrent cap (CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS, default 20; errorsubagent_concurrency_cap, new in 2.1.217) and a per-session cap (CLAUDE_CODE_MAX_SUBAGENTS_PER_SESSION, default 200; errorsubagent_count_cap, present since ≤2.1.215). The scenario schema’sdispatch_count_maxdescription, theassertover-budget message, and SPEC §10 no longer claim “Cowork imposes no in-conversation Task-dispatch cap” — that claim was stale. The harness does not reproduce the caps; it inherits them by running the binary.A host-loop
execinfrastructure failure now WARNS instead of failing the run. ⚠️ Upgrade note: a run that previously exited1because onedocker execfailed will now exit0. A dead sidecar still hard-fails. Known residual, documented indocs/scenario.md: if every exec failed the agent ran nothing and the run still only warns — inspectresult.infraErrorswhen a run looks suspiciously empty.A model-requested bash
timeout_msexpiry is no longer classified as an infrastructure failure. The model now receives its command’s own partial output withCommand timed out after <duration>merged into stderr — matching real Cowork, verified against the staged agent binary — instead of an opaque[infrastructure error: see run log for details].The agent’s spawn env now always carries a normalized IANA
TZ— matching Desktop, which injectsIntl.DateTimeFormat().resolvedOptions().timeZoneunconditionally. PreviouslyTZwas forwarded only when the host shell exported it, and forwarded raw, so a host with noTZset — or a legacy/non-IANA export (US/Eastern,EST5EDT) — diverged from real Cowork’s date/“today” rendering inside the agent.The
tool_availableassertion now names its evidence limit. It evaluates against the run’s eagerly-loaded tool set (the SDK init manifest inresult.json); a factory-deferred tool — e.g. the skill-discovery MCP tools, loaded on demand via aToolSearchround-trip — can be genuinely available in the run yet miss here, a false negative. The assertion still fails on a miss; the failure message now states that eagerly-loaded scope rather than implying provable unavailability.An explicitly requested
--dotenvfile now fails loud. ⚠️ Upgrade note: an unreadable file, or a path that is a directory, previously fell through to lower-precedence.envsources while still printing a success line — so a typo’d path silently ran against the wrong credentials. It is now a usage error. Automatic.envdiscovery is unchanged (still best-effort).diffno longer reportsidenticalwhen only one side has an artifact manifest. ⚠️ Upgrade note: such a comparison previously exited0; it now exits1, because unavailable evidence is not evidence of equality. Both-sides-missing still does not veto identity. The--output-format jsonenvelope gained anartifactsAvailabilitykey.stats --reindexmerges rows by per-completion identity (outDir+ a newturnfield) rather than byoutDiralone, and reports rejected symlinked run directories.
Fixed
verify-runnow REFUSES a multi-turn run directory instead of certifying the wrong turn. Rootresult.jsonis the latest turn; on acritiquedirectory that is the reflection turn while the scenario describes the graded one. Previously the cumulative gate scan false-FAILED on the other turn’s gates — wrong, but loud. The refusal namesresult.graded.json/turns/1/result.jsonso the caller can still reach the graded turn.traceno longer mixes turn scopes. After timeline reads became turn-scoped,--view tool-durationsshowed the latest turn while the tools/questions/dispatches views still showed every turn — two views of one run directory describing different scopes. All views are now the latest turn, and a::notice::reports when earlier turns exist rather than hiding them. Its cache-read footer and gate-provenance (answeredBy) views also now say when a result is not turn-addressable (a pre-layout dir) instead of silently omitting the cache-read ratio / labels.pruneno longer demotes an unmigrated pre-layout run dir to the junk tier. Its real-run predicate keyed onhasTurnDirs || events.jsonl, reasoning only about what current writers produce — butpruneranks history, including the legacy dirsmigrate-run-direxists to preserve, so a legacy dir with noevents.jsonlcould be evicted ahead of an empty scaffold. It now also counts alegacy/mixedshape as a real run. (Distinct from the in-flight-migration deferral above — this is about which dirs count as real at all.)A resumed turn was judged on the PRIOR turn’s evidence — three wrong-verdict paths.
events.jsonlis append-only across turns with no per-turn marker, and three whole-file scanners decide a run’s outcome:scanEvents(outputs-delete / host-path-leak → fail signals, and an authoredno_delete_in_outputs),findUngatedPathToolCalls(→ a run-levelerrorat hostloop), anddetectCapabilityUse(→missing_capability, a fail signal, which fires on the default lean image). So on any--resume— and everycritiquereflection turn — turn 1’s delete, gated tool call, or capability use FAILED turn 2. A turn-start marker now scopes all three to the current turn.resources.jsonlhad the same shape (turn 1’s peak RSS judged against turn 2’smax_peak_rss_bytes) and is archived per turn. Single-turn runs write no marker, so theirevents.jsonlis byte-identical and no cassette is affected. Missing marker ⇒ whole-file scan, i.e. fail-closed.A resumed turn’s telemetry included the PRIOR turn’s events, and could produce a false PASS.
timeline.jsonlis append-mode with a fresh header per turn, butreadTimelinereturned every line after the first as an event — so on any--resume(and everycritiquereflection turn) the current turn’stoolDurations/skillActivity/subagentsfolded in the previous turn’s tool calls. Because theskill_tool_usedassertion evaluates against that sameskillActivity, a turn-1 skill window could satisfy a turn-2 assertion. The reader now returns only the current turn’s segment. The file stays one append-only stream, socritique’s byte-offset turn-isolation proof is unaffected.A resumed turn destroyed the prior turn’s
trace.json. Because it is rebuilt and overwritten on every completion, the earlier turn’s trace was deleted rather than preserved, so acritiquelost the graded turn’s trace entirely. Each turn now owns its ownturns/<N>/trace.json, written once and never overwritten, andcritiqueadditionally writestrace.graded.jsonbesideresult.graded.json.stats --reindexdropped every non-latest turn when rebuilding from the runs tree. It read only the rootresult.jsonper run directory, so a resumed session’s earlier turns vanished — and on acritiquedirectory the root file is the reflection turn, so it was the graded rows that were lost. Every turn underturns/<N>/is now indexed as its own completion;result.graded.json— a root-level copy of the graded turn — is deliberately not matched, so it cannot double-count. A dir that has not been migrated is counted asskippedLegacyand reported with the remedy, never dropped silently.An ambient
GIT_DIRsilently computed the wrong skill file set. Git hooks exportGIT_DIR(andGIT_INDEX_FILE) into every child process, and withGIT_DIRset but noGIT_WORK_TREEgit stops inferring the work tree fromcwdand treatscwdas the repo root.gitTrackedSet’srev-parse --show-toplevelprobe therefore still succeeded — so the not-a-repo raw-walk fallback never fired — whilegit ls-files -- .returned the entire repo index as root-relative paths instead of the directory-relative ones. Measured on this repo: 2 tracked files became 625 wrong ones. That set feeds bothskillHashand the mount-copy filter, so any run invoked from a git hook (or from CI that exportsGIT_DIR) got a wrong hash and a mount filter pointed at paths that do not exist under the skill dir. The visible symptom was the repo’s own pre-commit hook reporting committed example cassettes as[stale] skill files changed since recordon every parity sync.skillCommithad the same defect:git -C <dir>is overridden by an ambientGIT_DIR, so every skill dir resolved to that foreign repo’s HEAD — recording a foreign commit as the skill’s provenance and masking dirs that are genuinely in different repos. Both call sites now spawn git withGIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILEstripped, via one shared helper so they cannot drift.run-index’sgitInfoanddoctor’s worktree probe deliberately keep inheriting — they are asking about the ambient repo.stats --reindexdestroyed multi-turn history. Every--resumeturn — andcritique’s task + reflection pair — writes to oneoutDir, so keying by directory collapsed N completions into one, silently changing run counts, pass rates and costs.Host-loop sidecar failures never reached the verdict. They were appended straight to
events.jsonl, which no live drive re-reads, so a dying sidecar left the run green; a signal-only termination (OOM,SIGKILL) was recorded nowhere at all.result.jsonwas written non-atomically at all three producers, so an interrupted write could leave the canonical record truncated.Corrupt index rows were blind-cast, letting one malformed row crash
statsor fabricate a pass/cost value;reindexalso followed symlinks out of the runs root.scaffoldturned unavailable artifact evidence into “no artifacts”, permanently encoding a false “this run produced nothing” claim into a generated scenario.critiquetreated a vanished turn-1 evidence file as genuinely empty evidence rather than an integrity failure. A stream that was legitimately zero bytes at capture is still reported clean.critique’s exit-code table omitted1. Exit1is reachable on operator interrupt (SIGINT/SIGTERM); a sweep wrapper treating it as impossible misreads a cancelled run as a crash.Documented that after critique’s resume,
result.jsonis the reflection turn’s result — the graded turn is archived asresult.turn-1.json. Reading the wrong one yields a valid-looking wrong number.
What’s Changed
- release: 1.7.0 by @yaniv-golan in https://github.com/yaniv-golan/cowork-harness/pull/61
Full Changelog: https://github.com/yaniv-golan/cowork-harness/compare/v1.6.0...v1.7.0