cowork-harness 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/cowork-harness/scripts/assertion-keys.json +14 -1
- package/.claude/skills/cowork-harness/scripts/scenario.py +30 -2
- package/CHANGELOG.md +48 -0
- package/README.md +2 -2
- package/dist/cli.js +3 -2
- package/dist/io.js +6 -5
- package/dist/run/cassette.js +174 -103
- package/dist/run/skill-hash.js +3 -2
- package/docs/cassette.md +9 -5
- package/docs/scenario.md +3 -1
- package/package.json +1 -1
- package/scripts/gen-schema.ts +10 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "GENERATED from the Zod
|
|
2
|
+
"$comment": "GENERATED from the Zod schemas (src/types.ts) by scripts/gen-schema.ts — do not edit; run `npm run schema`.",
|
|
3
3
|
"keys": [
|
|
4
4
|
"allow_l0_plugin_divergence",
|
|
5
5
|
"allow_missing_capability",
|
|
@@ -32,6 +32,19 @@
|
|
|
32
32
|
"transcript_not_matches",
|
|
33
33
|
"user_visible_artifact"
|
|
34
34
|
],
|
|
35
|
+
"topLevelKeys": [
|
|
36
|
+
"answers",
|
|
37
|
+
"assert",
|
|
38
|
+
"baseline",
|
|
39
|
+
"expect_denied",
|
|
40
|
+
"fidelity",
|
|
41
|
+
"name",
|
|
42
|
+
"on_unanswered",
|
|
43
|
+
"prompt",
|
|
44
|
+
"requires_capabilities",
|
|
45
|
+
"session",
|
|
46
|
+
"skills"
|
|
47
|
+
],
|
|
35
48
|
"verdictModifierKeys": [
|
|
36
49
|
"allow_l0_plugin_divergence",
|
|
37
50
|
"allow_missing_capability",
|
|
@@ -116,8 +116,11 @@ if UNCLASSIFIED_KEYS:
|
|
|
116
116
|
"— add them to the linter's CONTENT/GATE/MANIFEST/LIVE_ONLY/VERDICT_MODIFIER sets.",
|
|
117
117
|
file=sys.stderr,
|
|
118
118
|
)
|
|
119
|
-
#
|
|
120
|
-
|
|
119
|
+
# Embedded fallback for the top-level scenario keys — kept EQUAL to the generated `topLevelKeys`
|
|
120
|
+
# (test-enforced, like _CLASSIFIED_KEYS for assert keys) so a missing assertion-keys.json can't silently
|
|
121
|
+
# reintroduce key drift. `profile`/`assertions` are NOT here — they are deprecated aliases handled by the
|
|
122
|
+
# special-cases in the unknown-key check below (`k != "assertions" and k != "profile"`).
|
|
123
|
+
_EMBEDDED_TOP_LEVEL_KEYS = {
|
|
121
124
|
"name",
|
|
122
125
|
"baseline",
|
|
123
126
|
"session",
|
|
@@ -128,7 +131,32 @@ TOP_LEVEL_KEYS = {
|
|
|
128
131
|
"expect_denied",
|
|
129
132
|
"assert",
|
|
130
133
|
"skills", # F-6: opt-in skill-staleness hash scope
|
|
134
|
+
"requires_capabilities", # Fix 4b: scenario-level required-capability declaration (pre-flight gate)
|
|
131
135
|
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _load_top_level_keys():
|
|
139
|
+
"""The authoritative top-level scenario-key set, generated from the Zod ScenarioObject schema into
|
|
140
|
+
`assertion-keys.json` (so the unknown-key check can't drift and false-flag a valid key). Falls back to
|
|
141
|
+
the embedded `_EMBEDDED_TOP_LEVEL_KEYS` (kept equal to the generated list) with a loud warning if the
|
|
142
|
+
file is missing or predates the `topLevelKeys` field."""
|
|
143
|
+
p = Path(__file__).resolve().parent / "assertion-keys.json"
|
|
144
|
+
try:
|
|
145
|
+
keys = json.loads(p.read_text(encoding="utf-8")).get("topLevelKeys")
|
|
146
|
+
if not keys:
|
|
147
|
+
raise KeyError("topLevelKeys")
|
|
148
|
+
return set(keys)
|
|
149
|
+
except Exception:
|
|
150
|
+
print(
|
|
151
|
+
f"::warning:: assertion-keys.json missing or has no topLevelKeys next to scenario.py ({p}) — "
|
|
152
|
+
"using a built-in top-level-key list that may be stale (run `npm run schema`).",
|
|
153
|
+
file=sys.stderr,
|
|
154
|
+
)
|
|
155
|
+
return set(_EMBEDDED_TOP_LEVEL_KEYS)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# every valid top-level scenario key (generated from the zod ScenarioObject schema; see _load_top_level_keys)
|
|
159
|
+
TOP_LEVEL_KEYS = _load_top_level_keys()
|
|
132
160
|
REGEX_KEYS = {
|
|
133
161
|
"transcript_matches",
|
|
134
162
|
"transcript_not_matches",
|
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,54 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.15.0] — 2026-06-25
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **Staleness no longer masks a scoped skill's own drift behind a co-occurring shared change.** For a
|
|
14
|
+
`skills:`-scoped cassette, when BOTH the shared roots AND the scoped skill's own files changed, the
|
|
15
|
+
diagnosis previously reported only `shared root changed` and never the skill — the two buckets were
|
|
16
|
+
mutually exclusive (shared tested first). It now attributes drift per-bucket by the actual changed paths and
|
|
17
|
+
emits BOTH a `shared-root` and a `skill` finding when both moved, each naming its own files. The same
|
|
18
|
+
diagnosis (per-file `[N changed (…)]` detail and the `COWORK_HARNESS_DEBUG_SKILLHASH` hook) now also runs on
|
|
19
|
+
the `replay` lane, which previously had a separate, less-detailed copy. With `COWORK_HARNESS_AGENT_SCOPE=skill`
|
|
20
|
+
a changed `agents/<skill>.md` is attributed to that skill, matching the hash boundary.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **`replay --fail-on-skill-drift` no longer fails on a `COWORK_HARNESS_GITSET` / `COWORK_HARNESS_AGENT_SCOPE`
|
|
25
|
+
flip.** A record-vs-verify mismatch in either setting is now classed `format` ("re-record under the same
|
|
26
|
+
mode") rather than misattributed to skill/shared drift, so it is a non-failing warning under
|
|
27
|
+
`--fail-on-skill-drift` (which targets skill-source drift only). It still fails under `--strict` and still
|
|
28
|
+
reds `verify-cassettes`. Previously the `replay` lane mislabeled such a flip as `shared-root`/`skill` and
|
|
29
|
+
failed the skill-drift gate.
|
|
30
|
+
|
|
31
|
+
## [0.14.0] — 2026-06-25
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **`replay` surfaces staleness + skipped assertions in JSON.** Each `--output-format json` result now carries
|
|
36
|
+
`staleness[]` (class-tagged: `baseline` / `skill` / `shared-root` / `format` / `unverifiable-baseline` /
|
|
37
|
+
`unverifiable-skill`) and `skippedAssertions` (`{full, partial}`), so a token-free CI gate can see a stale
|
|
38
|
+
cassette or live-only assertions it didn't evaluate WITHOUT the verdict changing — a stale but otherwise
|
|
39
|
+
passing replay stays `ok:true` by default. Previously these were stderr-only `::warning::` lines invisible
|
|
40
|
+
to a JSON consumer.
|
|
41
|
+
- **`replay --fail-on-skill-drift`.** A narrower release gate than `--strict`: fails only on skill-source drift
|
|
42
|
+
(`skill` / `shared-root` / `unverifiable-skill` — "can't verify the skill ⇒ not green"), while baseline /
|
|
43
|
+
format / environment-level staleness stays a non-failing warning. `--strict` remains the superset (fails on
|
|
44
|
+
every class).
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- **Linter no longer false-flags `requires_capabilities`.** `cowork-harness lint` warned `unknown-top-key` on
|
|
49
|
+
the valid scenario field `requires_capabilities` (its hand-maintained top-level-key list had drifted from the
|
|
50
|
+
schema). The list is now generated from the Zod `ScenarioObject` schema (like the assertion-key list), so it
|
|
51
|
+
can't drift; a parity test guards it.
|
|
52
|
+
- **SKILL.md capability pre-flight wording corrected.** It described `requires_capabilities` as skill-declared
|
|
53
|
+
and said the harness "warns before the run"; it is a **scenario field** and the harness **aborts before the
|
|
54
|
+
paid run (exit 3)** when the image omits a required capability (unless `allow_missing_capability: true`). The
|
|
55
|
+
authoritative `docs/scenario.md` was already correct.
|
|
56
|
+
|
|
9
57
|
## [0.13.0] — 2026-06-25
|
|
10
58
|
|
|
11
59
|
### Added
|
package/README.md
CHANGED
|
@@ -93,7 +93,7 @@ Skill testing is the headline use, but the tool is a general harness over the Co
|
|
|
93
93
|
| `skill <folder> "<prompt>"` | Run a local skill/plugin folder once against the staged agent | ad-hoc "is the skill alive / does it do X?" — the fast inner loop |
|
|
94
94
|
| `run <scenario.yaml \| dir/>` | Run authored scenarios with `assert:` + a CI-ready exit code | you want a repeatable, **asserted regression test** |
|
|
95
95
|
| `chat <folder> [prompt]` | Interactive multi-turn REPL against a skill (TTY); optional seed prompt is sent as the first turn. `--upload <file>` / `--folder <dir>` (repeatable) attach files/project folders; `--verbose` / `-V` shows thinking blocks + tool inputs; `--fidelity protocol\|container\|hostloop` | debugging a multi-turn flow by hand |
|
|
96
|
-
| `record` / `replay` | `record` saves a control-protocol cassette (one scenario, or batch a `dir/`; `--rerecord-stale` refreshes only drifted ones; `--concurrency <N>` records a batch N at a time — runs are fully isolated, so it just bounds Docker/API pressure; `--max-artifact-bytes` raises the 64 KiB inline-body cap; `--dry-run` previews the scenarios + token/binary checks without recording; answer gates **live during the recording** with `--decider-dir`/`--decider-llm`/`--on-unanswered first` instead of pre-scripting them — a live decider flags the cassette non-deterministic but it still replays deterministically); `replay <file.cassette.json \| dir/>` runs a cassette **file or a `dir/` of `*.cassette.json`** deterministically (`--strict` fails on
|
|
96
|
+
| `record` / `replay` | `record` saves a control-protocol cassette (one scenario, or batch a `dir/`; `--rerecord-stale` refreshes only drifted ones; `--concurrency <N>` records a batch N at a time — runs are fully isolated, so it just bounds Docker/API pressure; `--max-artifact-bytes` raises the 64 KiB inline-body cap; `--dry-run` previews the scenarios + token/binary checks without recording; answer gates **live during the recording** with `--decider-dir`/`--decider-llm`/`--on-unanswered first` instead of pre-scripting them — a live decider flags the cassette non-deterministic but it still replays deterministically); `replay <file.cassette.json \| dir/>` runs a cassette **file or a `dir/` of `*.cassette.json`** deterministically (`--strict` fails on any stale one, `--fail-on-skill-drift` only on skill-source drift; staleness is also reported per-result in `staleness[]` for a JSON gate; exits on the worst verdict) | **token-free, Docker-free CI** from a once-recorded run |
|
|
97
97
|
| `verify-cassettes <file\|dir>` | Token-free CI gate over committed cassettes: a privacy scan (email/currency/domain → exit 1; whole-token allows via `--allow` / class-scoped `--allow-domain` / `--allow-email` / `--allow-file`) + a staleness check (`--skip-privacy` or `--skip-staleness` to run only one half). A dir argument scans `*.cassette.json` in that dir only (**non-recursive**) | gating **committed cassettes** against PII leaks + "edited the skill, forgot to re-record" |
|
|
98
98
|
| `verify-run <run-dir> <scenario.yaml>` | Re-evaluate a scenario's `assert:` (and, when the scenario declares `answers:`, whether they still match the run's actual gates) against an already-kept run dir — **no live agent, no tokens, no Docker** (~1s) | iterating on a wrong assertion or a drifted `answer` without a full live re-record |
|
|
99
99
|
| `trace <run-id>` | Digest a run's `events.jsonl` (`--view tools\|questions\|dispatches`; legacy `--tools` / `--gates` / `--dispatches` aliases still accepted) | "how many sub-agents *actually* dispatched, and which?" |
|
|
@@ -208,7 +208,7 @@ This repo ships a **companion skill** (`.claude/skills/cowork-harness/`) that te
|
|
|
208
208
|
/plugin install cowork-harness@cowork-harness
|
|
209
209
|
```
|
|
210
210
|
|
|
211
|
-
The skill **self-bootstraps the CLI**: if `cowork-harness` isn't on your PATH it falls back to `npx cowork-harness@>=0.
|
|
211
|
+
The skill **self-bootstraps the CLI**: if `cowork-harness` isn't on your PATH it falls back to `npx cowork-harness@>=0.15.0` (a version floor that fails loud rather than silently fetching a too-old CLI; Node ≥ 20). Tiers above `protocol` still need Docker/Lima and a Claude Desktop agent binary — see the prerequisites below.
|
|
212
212
|
|
|
213
213
|
It also follows the open [Agent Skills](https://github.com/vercel-labs/skills) spec, so it installs cross-editor (Cursor, Codex, OpenCode, …) by pointing the `npx skills` CLI at `.claude/skills/cowork-harness` in this repo. (Working *inside* this repo, the skill auto-loads as a project skill — no install needed.)
|
|
214
214
|
|
package/dist/cli.js
CHANGED
|
@@ -72,7 +72,8 @@ const HELP = `cowork-harness <command> (v${"$VERSION"})
|
|
|
72
72
|
[--decider-llm [--intent "…"]] | [--on-unanswered fail|first] answer live via a model / auto-pick
|
|
73
73
|
(a live decider flags the cassette non-deterministic; it still replays deterministically)
|
|
74
74
|
replay <file|dir> deterministic protocol-replay of a cassette or a dir of them (no token, no Docker)
|
|
75
|
-
[--strict] fail (exit 1) on
|
|
75
|
+
[--strict] fail (exit 1) on ANY stale cassette instead of warning
|
|
76
|
+
[--fail-on-skill-drift] fail only on skill-source drift (skill/shared-root); baseline drift stays a warning
|
|
76
77
|
[--output-format json]
|
|
77
78
|
verify-cassettes <file|dir> CI gate (no token): privacy scan + staleness — exit 1 on finding or drift
|
|
78
79
|
[--skip-privacy|--skip-staleness] skip one check
|
|
@@ -262,7 +263,7 @@ const SUBCOMMAND_USAGE = {
|
|
|
262
263
|
' answer gates LIVE: [--decider-dir <dir>] (single scenario only) | [--decider-llm [--intent "<one line>"]] | [--on-unanswered fail|first]\n' +
|
|
263
264
|
" (a live decider flags the cassette non-deterministic — re-recording may drift; replay stays deterministic. --rerecord-stale rejects these flags.)\n" +
|
|
264
265
|
" NOTE: --allow-failing only relaxes the post-run VERDICT gate; it does NOT salvage an unanswered gate (that throws before any cassette is written — use --on-unanswered first / a decider).",
|
|
265
|
-
replay: "usage: replay <file.cassette.json | dir/> [--strict] [--output-format text|json]",
|
|
266
|
+
replay: "usage: replay <file.cassette.json | dir/> [--strict] [--fail-on-skill-drift] [--output-format text|json]",
|
|
266
267
|
"verify-cassettes": "usage: verify-cassettes <file|dir> [--skip-privacy|--skip-staleness] [--allow <regex>]... [--allow-domain <regex>]... [--allow-email <regex>]... [--allow-file <path>]... [--output-format json]",
|
|
267
268
|
trace: "usage: trace <run-id | run-dir | events.jsonl> [--view tools|questions|dispatches] [--output-format json]\n --view tools tool call / result rows\n --view questions gate lifecycle (question → answer → delivered)\n --view dispatches sub-agent dispatch tree + dispatch_count_max\n (default: all views)\n (for what the run PRODUCED — artifacts — use `inspect`)",
|
|
268
269
|
assertions: "usage: assertions --list [--output-format json]",
|
package/dist/io.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Emit a structured warning to stderr with the GitHub-actions `::warning::` annotation prefix — the one
|
|
3
|
-
* place warning formatting/severity lives, so call sites pass only the message body.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* place warning formatting/severity lives, so call sites pass only the message body. A message that ALREADY
|
|
4
|
+
* carries a GitHub annotation prefix (`::warning::`, `::notice::`, or `::error::`) is written as-is, so a
|
|
5
|
+
* call site that wants a softer/harder severity (e.g. `::notice:: …`) gets exactly that — NOT a doubled
|
|
6
|
+
* `::warning:: ::notice:: …`. Uses `process.stderr.write` (the mechanism the warnings always used) so test
|
|
7
|
+
* spies on it still observe warnings and the output is byte-identical for plain `::warning::` content.
|
|
7
8
|
*/
|
|
8
9
|
export function warn(message) {
|
|
9
|
-
const line =
|
|
10
|
+
const line = /^::(warning|notice|error)::/.test(message) ? message : `::warning:: ${message}`;
|
|
10
11
|
process.stderr.write(line.endsWith("\n") ? line : line + "\n");
|
|
11
12
|
}
|
package/dist/run/cassette.js
CHANGED
|
@@ -22,7 +22,7 @@ import { jsonEnvelope } from "./envelope.js";
|
|
|
22
22
|
import { parseArgs } from "../cli-args.js";
|
|
23
23
|
import { resolveInputs } from "./inputs.js";
|
|
24
24
|
import { realProbe } from "./doctor.js";
|
|
25
|
-
import { hashSkillDirs, hashSharedOnly, computeContentSig, skillHashEntries, OS_JUNK_PATTERN } from "./skill-hash.js";
|
|
25
|
+
import { hashSkillDirs, hashSharedOnly, computeContentSig, skillHashEntries, OS_JUNK_PATTERN, agentSkillName } from "./skill-hash.js";
|
|
26
26
|
import { computeVerdict } from "./verdict.js";
|
|
27
27
|
import { redactJsonLine, redactText, redactStructural, loadRedactionPolicy } from "../redact.js";
|
|
28
28
|
import { collectSecrets, scrubField } from "../secrets.js";
|
|
@@ -424,12 +424,8 @@ function debugSkillHashMismatch(cassette, cassetteDir, fp, live) {
|
|
|
424
424
|
`contentSig ${sigVerdict}${junk ? ` · ${junk} OS-junk file(s) flagged above` : ""}. ` +
|
|
425
425
|
`Note: this lists the CURRENT tree; a true per-file diff vs record needs the record-time set (re-record after excluding junk, then compare).\n`);
|
|
426
426
|
}
|
|
427
|
-
/** v5: diff two per-file manifests (recorded vs live) into
|
|
428
|
-
|
|
429
|
-
* caller can append it to the bucket/generic message (preserving the G-4 shared-vs-scoped semantic). Samples
|
|
430
|
-
* up to 3 paths per category. Null when the manifests are equal (hashes differ but files don't — caller
|
|
431
|
-
* falls back to its bucket message). */
|
|
432
|
-
function diffFileSigs(recorded, live) {
|
|
427
|
+
/** v5: diff two per-file manifests (recorded vs live) into the exact changed/added/removed path lists. */
|
|
428
|
+
function diffFileSigsPaths(recorded, live) {
|
|
433
429
|
const rec = new Map(recorded);
|
|
434
430
|
const liv = new Map(live);
|
|
435
431
|
const added = [];
|
|
@@ -444,26 +440,67 @@ function diffFileSigs(recorded, live) {
|
|
|
444
440
|
for (const [p] of rec)
|
|
445
441
|
if (!liv.has(p))
|
|
446
442
|
removed.push(p);
|
|
447
|
-
|
|
443
|
+
return { added, removed, changed };
|
|
444
|
+
}
|
|
445
|
+
/** Format a {@link FileSigDiff} into an actionable summary (samples up to 3 paths per category). Null when
|
|
446
|
+
* the diff is empty (hashes differ but files don't — a structure-only change; caller falls back to its
|
|
447
|
+
* bucket message). */
|
|
448
|
+
function summarizeFileSigDiff(diff) {
|
|
449
|
+
if (!diff.added.length && !diff.removed.length && !diff.changed.length)
|
|
448
450
|
return null;
|
|
449
451
|
const sample = (a) => `${a.slice(0, 3).join(", ")}${a.length > 3 ? `, +${a.length - 3} more` : ""}`;
|
|
450
452
|
const parts = [];
|
|
451
|
-
if (changed.length)
|
|
452
|
-
parts.push(`${changed.length} changed (${sample(changed)})`);
|
|
453
|
-
if (added.length)
|
|
454
|
-
parts.push(`${added.length} added (${sample(added)})`);
|
|
455
|
-
if (removed.length)
|
|
456
|
-
parts.push(`${removed.length} removed (${sample(removed)})`);
|
|
453
|
+
if (diff.changed.length)
|
|
454
|
+
parts.push(`${diff.changed.length} changed (${sample(diff.changed)})`);
|
|
455
|
+
if (diff.added.length)
|
|
456
|
+
parts.push(`${diff.added.length} added (${sample(diff.added)})`);
|
|
457
|
+
if (diff.removed.length)
|
|
458
|
+
parts.push(`${diff.removed.length} removed (${sample(diff.removed)})`);
|
|
457
459
|
return parts.join("; ");
|
|
458
460
|
}
|
|
459
|
-
/**
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
461
|
+
/** Convenience: diff + summarize in one call (the non-scoped/whole-tree caller wants only the summary). */
|
|
462
|
+
function diffFileSigs(recorded, live) {
|
|
463
|
+
return summarizeFileSigDiff(diffFileSigsPaths(recorded, live));
|
|
464
|
+
}
|
|
465
|
+
/** Finding 1 (G-4, path-accurate): partition a manifest diff into the shared-root bucket vs the
|
|
466
|
+
* skill-private bucket, EXACTLY mirroring `scopedAccept`/`sharedOnlyAccept` in skill-hash.ts so attribution
|
|
467
|
+
* matches the hash boundary. A path under `skills/<name>/` is skill-private when `<name>` is in scope; with
|
|
468
|
+
* agent-scoping ON, a skill-named `agents/<name>.md` is also skill-private. Everything else is shared. This
|
|
469
|
+
* lets `computeStaleness` emit a `skill` finding AND a `shared-root` finding when BOTH buckets drift, so a
|
|
470
|
+
* co-occurring shared change can no longer mask the skill's own drift (the original bug). */
|
|
471
|
+
function partitionDriftBuckets(diff, scope, scopeAgents) {
|
|
472
|
+
const shared = { added: [], removed: [], changed: [] };
|
|
473
|
+
const skill = { added: [], removed: [], changed: [] };
|
|
474
|
+
const isSkillPrivate = (relPath) => {
|
|
475
|
+
const parts = relPath.split("/");
|
|
476
|
+
if (parts[0] === "skills" && parts.length >= 2)
|
|
477
|
+
return scope.has(parts[1]);
|
|
478
|
+
if (scopeAgents) {
|
|
479
|
+
const an = agentSkillName(parts);
|
|
480
|
+
if (an !== null && scope.has(an))
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
return false;
|
|
484
|
+
};
|
|
485
|
+
for (const cat of ["added", "removed", "changed"])
|
|
486
|
+
for (const p of diff[cat])
|
|
487
|
+
(isSkillPrivate(p) ? skill : shared)[cat].push(p);
|
|
488
|
+
return { shared, skill };
|
|
489
|
+
}
|
|
490
|
+
/** The SINGLE staleness diagnosis (unifies what used to be two divergent copies: `checkStaleness` and the
|
|
491
|
+
* inline block in `replayCassette`). Recompute the fingerprint and report drift as class-tagged findings;
|
|
492
|
+
* each CALLER applies its own gate-vs-warn policy:
|
|
493
|
+
* - `verify-cassettes` / the re-record work-list go through the `checkStaleness` string adapter and treat
|
|
494
|
+
* ANY finding as "stale ⇒ re-record" (so `unverifiable-*` stays a hard fail there — can't verify ⇒ not
|
|
495
|
+
* green). The adapter MUST be class-blind (forward every finding) or that gate false-greens.
|
|
496
|
+
* - `replayCassette` consumes the findings directly: warn by default, `--strict` fails on all,
|
|
497
|
+
* `--fail-on-skill-drift` fails only on `SKILL_DRIFT_CLASSES`.
|
|
498
|
+
* No fingerprint → nothing to check (legacy cassette). */
|
|
499
|
+
export function computeStaleness(cassette, cassetteDir) {
|
|
463
500
|
const fp = cassette.fingerprint;
|
|
464
501
|
if (!fp)
|
|
465
502
|
return [];
|
|
466
|
-
const
|
|
503
|
+
const findings = [];
|
|
467
504
|
let liveBaseline;
|
|
468
505
|
try {
|
|
469
506
|
liveBaseline = loadBaseline("latest").appVersion;
|
|
@@ -471,54 +508,114 @@ export function checkStaleness(cassette, cassetteDir) {
|
|
|
471
508
|
catch {
|
|
472
509
|
/* baseline not loadable */
|
|
473
510
|
}
|
|
474
|
-
//
|
|
475
|
-
//
|
|
511
|
+
// The cassette carries a baseline-of-record but we can't load the current one to compare. Surfaced as
|
|
512
|
+
// `unverifiable-baseline` (env/platform, not skill drift): a non-failing warning on the default replay gate,
|
|
513
|
+
// but a hard fail for `verify-cassettes`/the work-list via the class-blind string adapter (can't verify ⇒
|
|
514
|
+
// not green). baselines ship with the package, so this is rare.
|
|
476
515
|
if (liveBaseline === undefined)
|
|
477
|
-
|
|
516
|
+
findings.push({
|
|
517
|
+
class: "unverifiable-baseline",
|
|
518
|
+
message: "cannot load the latest baseline to verify staleness — run `cowork-harness sync` or ship baselines/ (env/platform, not skill drift; can't verify ⇒ not green)",
|
|
519
|
+
});
|
|
478
520
|
else if (liveBaseline !== fp.baseline)
|
|
479
|
-
|
|
521
|
+
findings.push({ class: "baseline", message: `baseline moved ${fp.baseline} → ${liveBaseline} since record — re-record` });
|
|
480
522
|
if (fp.skillHash) {
|
|
481
523
|
const live = buildFingerprint(cassette.scenario.session, fp.baseline, cassetteDir, cassette.scenario.skills);
|
|
482
524
|
const recMode = fp.mode ?? "raw";
|
|
483
525
|
const liveMode = live.mode ?? "raw";
|
|
484
526
|
if (live.skillHash === undefined)
|
|
485
|
-
|
|
527
|
+
findings.push({
|
|
528
|
+
class: "unverifiable-skill",
|
|
529
|
+
message: "skill dirs not resolvable from the cassette location — cannot verify skill staleness (can't verify ⇒ not green)",
|
|
530
|
+
});
|
|
486
531
|
else if (recMode !== liveMode)
|
|
487
|
-
// Phase C: a hash from a different boundary mode is not comparable — don't emit a misleading
|
|
488
|
-
|
|
532
|
+
// Phase C: a hash from a different boundary mode is not comparable — re-record, don't emit a misleading
|
|
533
|
+
// content diff. Classed `format` (not skill drift): a mode flip is an env/config mismatch, not source drift.
|
|
534
|
+
findings.push({
|
|
535
|
+
class: "format",
|
|
536
|
+
message: `recorded in '${recMode}' file-set mode, verifying in '${liveMode}' (COWORK_HARNESS_GITSET) — re-record under the same mode`,
|
|
537
|
+
});
|
|
489
538
|
else if ((fp.agentScope ?? "off") !== (live.agentScope ?? "off"))
|
|
490
539
|
// Agent-scoping flip (COWORK_HARNESS_AGENT_SCOPE): the scoped hash covers a different file set, so it's
|
|
491
540
|
// not comparable — re-record under the same setting (mirrors the GITSET mode flip above).
|
|
492
|
-
|
|
541
|
+
findings.push({
|
|
542
|
+
class: "format",
|
|
543
|
+
message: `recorded with agent-scope '${fp.agentScope ?? "off"}', verifying with '${live.agentScope ?? "off"}' (COWORK_HARNESS_AGENT_SCOPE) — re-record under the same setting`,
|
|
544
|
+
});
|
|
493
545
|
else if (live.skillHash !== fp.skillHash) {
|
|
494
|
-
debugSkillHashMismatch(cassette, cassetteDir, fp, live); // H9: surface WHICH files drifted
|
|
546
|
+
debugSkillHashMismatch(cassette, cassetteDir ?? "", fp, live); // H9 (D2): surface WHICH files drifted
|
|
495
547
|
const recordedVersion = cassette.cassetteVersion ?? 0;
|
|
496
|
-
// v5: name the EXACT changed/added/removed file(s) from the per-file manifest, APPENDED to the
|
|
497
|
-
// bucket/generic message so the G-4 shared-vs-scoped semantic is preserved AND the file is named.
|
|
498
|
-
const summary = fp.fileSigs && live.fileSigs ? diffFileSigs(fp.fileSigs, live.fileSigs) : null;
|
|
499
|
-
const detail = summary ? ` [${summary}]` : "";
|
|
500
548
|
if (recordedVersion < CASSETTE_VERSION) {
|
|
501
|
-
|
|
549
|
+
findings.push({
|
|
550
|
+
class: "format",
|
|
551
|
+
message: `recorded under an older hash format (v${recordedVersion} → v${CASSETTE_VERSION}) — re-record once after upgrading`,
|
|
552
|
+
});
|
|
502
553
|
}
|
|
503
554
|
else if (fp.sharedHash !== undefined && live.sharedHash !== undefined) {
|
|
504
|
-
// G-4
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
555
|
+
// G-4 + Finding 1: attribute drift to the shared and/or skill bucket(s). D6: `skillScope` is always
|
|
556
|
+
// non-empty when `sharedHash` is set (single assignment site under the same guard in buildFingerprint);
|
|
557
|
+
// the `?? []` is a defensive guard only — the on-disk cassette shape is not schema-validated.
|
|
558
|
+
const scopeArr = fp.skillScope ?? [];
|
|
559
|
+
const scopeLabel = scopeArr.map((s) => `skills/${s}`).join(", ") || "skill";
|
|
560
|
+
const scopeSet = new Set(scopeArr);
|
|
561
|
+
const scopeAgents = (live.agentScope ?? "off") === "skill";
|
|
562
|
+
if (fp.fileSigs && live.fileSigs) {
|
|
563
|
+
// Path-accurate: emit a finding per bucket that ACTUALLY changed, so a co-occurring shared change can
|
|
564
|
+
// never mask the skill's own drift (the original bug) and vice-versa.
|
|
565
|
+
const { shared, skill } = partitionDriftBuckets(diffFileSigsPaths(fp.fileSigs, live.fileSigs), scopeSet, scopeAgents);
|
|
566
|
+
const sharedSummary = summarizeFileSigDiff(shared);
|
|
567
|
+
const skillSummary = summarizeFileSigDiff(skill);
|
|
568
|
+
if (sharedSummary)
|
|
569
|
+
findings.push({
|
|
570
|
+
class: "shared-root",
|
|
571
|
+
message: `shared root changed since record (scope: ${scopeLabel}) [${sharedSummary}] — re-record`,
|
|
572
|
+
});
|
|
573
|
+
if (skillSummary)
|
|
574
|
+
findings.push({ class: "skill", message: `${scopeLabel} changed since record [${skillSummary}] — re-record` });
|
|
575
|
+
if (!sharedSummary && !skillSummary) {
|
|
576
|
+
// Hashes differ but the per-file manifest shows no path change (structure-only: an empty dir or a
|
|
577
|
+
// symlink re-point). Fall back to the hash buckets; emit BOTH classes if the shared hash moved so
|
|
578
|
+
// neither bucket is masked.
|
|
579
|
+
if (live.sharedHash !== fp.sharedHash)
|
|
580
|
+
findings.push({ class: "shared-root", message: `shared root changed since record (scope: ${scopeLabel}) — re-record` });
|
|
581
|
+
findings.push({ class: "skill", message: `${scopeLabel} changed since record — re-record` });
|
|
582
|
+
}
|
|
508
583
|
}
|
|
509
584
|
else {
|
|
510
|
-
|
|
585
|
+
// Pre-detail cassette (no per-file manifest, e.g. > MANIFEST_MAX_FILES): can't isolate the bucket.
|
|
586
|
+
// Emit BOTH classes when the shared hash moved so the skill's own drift is never masked (over-warns,
|
|
587
|
+
// but the gate is already red and both classes are in the fail set).
|
|
588
|
+
if (live.sharedHash !== fp.sharedHash) {
|
|
589
|
+
findings.push({ class: "shared-root", message: `shared root changed since record (scope: ${scopeLabel}) — re-record` });
|
|
590
|
+
findings.push({
|
|
591
|
+
class: "skill",
|
|
592
|
+
message: `${scopeLabel} may also have changed since record (no per-file manifest to isolate) — re-record`,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
findings.push({ class: "skill", message: `${scopeLabel} changed since record — re-record` });
|
|
597
|
+
}
|
|
511
598
|
}
|
|
512
599
|
}
|
|
513
|
-
else if (summary) {
|
|
514
|
-
msgs.push(`skill files changed since record — ${summary} — re-record`);
|
|
515
|
-
}
|
|
516
600
|
else {
|
|
517
|
-
|
|
601
|
+
// Non-scoped (whole-tree) cassette: name the changed files when the per-file manifest is present (D5),
|
|
602
|
+
// else the generic fallback.
|
|
603
|
+
const summary = fp.fileSigs && live.fileSigs ? diffFileSigs(fp.fileSigs, live.fileSigs) : null;
|
|
604
|
+
if (summary)
|
|
605
|
+
findings.push({ class: "skill", message: `skill files changed since record — ${summary} — re-record` });
|
|
606
|
+
else
|
|
607
|
+
findings.push({ class: "skill", message: "local skill/plugin dir contents changed since record — re-record" });
|
|
518
608
|
}
|
|
519
609
|
}
|
|
520
610
|
}
|
|
521
|
-
return
|
|
611
|
+
return findings;
|
|
612
|
+
}
|
|
613
|
+
/** B3 staleness GATE adapter for the string consumers (`verify-cassettes`, the re-record work-list). Returns
|
|
614
|
+
* the unified findings as plain reason strings. MUST stay class-blind (forward EVERY finding) so an
|
|
615
|
+
* `unverifiable-baseline` / `unverifiable-skill` still reds those gates — filtering a class here would
|
|
616
|
+
* false-green `verify-cassettes` on a cassette it can't verify. */
|
|
617
|
+
export function checkStaleness(cassette, cassetteDir) {
|
|
618
|
+
return computeStaleness(cassette, cassetteDir).map((f) => f.message);
|
|
522
619
|
}
|
|
523
620
|
/** A minimal RunRecord for a truncated-cassette replay — empty collections so downstream evaluate()/the
|
|
524
621
|
* mismatch loops don't NPE; result:"error" because the cassette could not be driven to completion. */
|
|
@@ -1520,7 +1617,7 @@ export async function cmdReplay(args) {
|
|
|
1520
1617
|
try {
|
|
1521
1618
|
p = parseArgs(args, {
|
|
1522
1619
|
// --quiet/--verbose accepted for flag consistency but currently no-op in replay (renderer plan is fixed).
|
|
1523
|
-
booleans: ["--strict", "--quiet", "--verbose"],
|
|
1620
|
+
booleans: ["--strict", "--fail-on-skill-drift", "--quiet", "--verbose"],
|
|
1524
1621
|
values: ["--output-format"],
|
|
1525
1622
|
enums: { "--output-format": ["text", "json"] },
|
|
1526
1623
|
aliases: { "-q": "--quiet", "-V": "--verbose" },
|
|
@@ -1532,7 +1629,7 @@ export async function cmdReplay(args) {
|
|
|
1532
1629
|
}
|
|
1533
1630
|
const target = p.positionals[0];
|
|
1534
1631
|
if (!target) {
|
|
1535
|
-
log("usage: replay <file.cassette.json | dir/> [--strict] [--output-format text|json]");
|
|
1632
|
+
log("usage: replay <file.cassette.json | dir/> [--strict] [--fail-on-skill-drift] [--output-format text|json]");
|
|
1536
1633
|
return process.exit(2);
|
|
1537
1634
|
}
|
|
1538
1635
|
if (p.positionals.length > 1) {
|
|
@@ -1540,7 +1637,10 @@ export async function cmdReplay(args) {
|
|
|
1540
1637
|
return process.exit(2);
|
|
1541
1638
|
}
|
|
1542
1639
|
const json = p.options["--output-format"] === "json";
|
|
1543
|
-
const strict = p.flags["--strict"] ?? false; // #1b: escalate staleness
|
|
1640
|
+
const strict = p.flags["--strict"] ?? false; // #1b: escalate ALL staleness findings to failures (release gate)
|
|
1641
|
+
const failOnSkillDrift = p.flags["--fail-on-skill-drift"] ?? false; // narrower gate: only skill-source drift fails
|
|
1642
|
+
if (strict && failOnSkillDrift)
|
|
1643
|
+
warn("::notice:: [replay] --strict and --fail-on-skill-drift both passed — --strict is the superset (fails on every class), so --fail-on-skill-drift is redundant here\n");
|
|
1544
1644
|
const resolved = resolveInputs(target, ".cassette.json");
|
|
1545
1645
|
if ("error" in resolved) {
|
|
1546
1646
|
log(`replay: ${resolved.error}`);
|
|
@@ -1564,7 +1664,7 @@ export async function cmdReplay(args) {
|
|
|
1564
1664
|
// fine" (false-green by abort). Wrap per-file: turn an unexpected throw into a tallied error result.
|
|
1565
1665
|
let result;
|
|
1566
1666
|
try {
|
|
1567
|
-
result = await replayCassette(rc.cassette, renderer ? [renderer] : [], { strict, cassetteDir: dirname(f) });
|
|
1667
|
+
result = await replayCassette(rc.cassette, renderer ? [renderer] : [], { strict, failOnSkillDrift, cassetteDir: dirname(f) });
|
|
1568
1668
|
}
|
|
1569
1669
|
catch (e) {
|
|
1570
1670
|
log(`replay: ${f}: ${e?.message ?? String(e)}`);
|
|
@@ -1860,7 +1960,10 @@ export function cmdRehash(args) {
|
|
|
1860
1960
|
}
|
|
1861
1961
|
/** Replay a cassette through Run and re-evaluate the content assertions. With a `cassette.artifacts`
|
|
1862
1962
|
* manifest (#1), filesystem assertions (file_exists/user_visible_artifact/artifact_json) ALSO run, against
|
|
1863
|
-
* the materialized snapshot. `opts.strict` (#1b) escalates staleness
|
|
1963
|
+
* the materialized snapshot. `opts.strict` (#1b) escalates ALL staleness findings to failing assertions;
|
|
1964
|
+
* `opts.failOnSkillDrift` escalates only the skill-source classes (`skill`/`shared-root`/`unverifiable-skill`),
|
|
1965
|
+
* leaving baseline drift a non-failing warning. Either way the findings are always surfaced in
|
|
1966
|
+
* `RunResult.staleness` for JSON consumers. */
|
|
1864
1967
|
export async function replayCassette(cassette, hooks = [], opts = {}) {
|
|
1865
1968
|
// Cassette format version: ABSENT = legacy (0); a FUTURE version means this harness may misread fields
|
|
1866
1969
|
// it doesn't know about. in strict mode this is a hard failure (future semantics may not be
|
|
@@ -1884,59 +1987,16 @@ export async function replayCassette(cassette, hooks = [], opts = {}) {
|
|
|
1884
1987
|
// unchanged (frozen-structure limit). The skill-hash recompute needs the local skill dirs to be resolvable
|
|
1885
1988
|
// from the cassette's session path; when they aren't (a moved/committed cassette), we say so rather than
|
|
1886
1989
|
// silently skipping.
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
if (opts.strict) {
|
|
1898
|
-
staleness.push("baseline could not be loaded — cannot verify staleness (--strict: treating as stale)");
|
|
1899
|
-
}
|
|
1900
|
-
else {
|
|
1901
|
-
warn("cowork-harness: strict staleness check skipped — baseline could not be loaded\n");
|
|
1902
|
-
}
|
|
1903
|
-
}
|
|
1904
|
-
if (!baselineLoadFailed && liveBaseline && liveBaseline !== fp.baseline)
|
|
1905
|
-
staleness.push(`baseline moved ${fp.baseline} → ${liveBaseline} since record — re-record before trusting this replay`);
|
|
1906
|
-
if (fp.skillHash) {
|
|
1907
|
-
const live = buildFingerprint(cassette.scenario.session, fp.baseline, opts.cassetteDir, cassette.scenario.skills);
|
|
1908
|
-
if (live.skillHash === undefined) {
|
|
1909
|
-
// under --strict, unresolvable skill dirs are a failure (can't verify ⇒ not green).
|
|
1910
|
-
// Without --strict, warn but continue (the baseline check still applies).
|
|
1911
|
-
if (opts.strict) {
|
|
1912
|
-
staleness.push("skill dirs not resolvable from this cassette location — cannot verify skill staleness (--strict: treating as stale)");
|
|
1913
|
-
}
|
|
1914
|
-
else {
|
|
1915
|
-
warn("::warning:: [replay] skill fingerprint not re-checkable (local skill dirs not resolvable from this cassette location) — baseline check still applies\n");
|
|
1916
|
-
}
|
|
1917
|
-
}
|
|
1918
|
-
else if (live.skillHash !== fp.skillHash) {
|
|
1919
|
-
const recordedVersion = cassette.cassetteVersion ?? 0;
|
|
1920
|
-
if (recordedVersion < CASSETTE_VERSION) {
|
|
1921
|
-
staleness.push(`recorded under an older hash format (v${recordedVersion} → v${CASSETTE_VERSION}) — re-record once after upgrading`);
|
|
1922
|
-
}
|
|
1923
|
-
else if (fp.sharedHash !== undefined && live.sharedHash !== undefined) {
|
|
1924
|
-
const scope = fp.skillScope?.length ? fp.skillScope.map((s) => `skills/${s}`).join(", ") : "skill";
|
|
1925
|
-
if (live.sharedHash !== fp.sharedHash) {
|
|
1926
|
-
staleness.push(`shared root changed since record (scope: ${scope}) — re-record before trusting this replay`);
|
|
1927
|
-
}
|
|
1928
|
-
else {
|
|
1929
|
-
staleness.push(`${scope} changed since record — re-record before trusting this replay`);
|
|
1930
|
-
}
|
|
1931
|
-
}
|
|
1932
|
-
else {
|
|
1933
|
-
staleness.push("local skill/plugin dir contents changed since record — re-record before trusting this replay");
|
|
1934
|
-
}
|
|
1935
|
-
}
|
|
1936
|
-
}
|
|
1937
|
-
for (const s of staleness)
|
|
1938
|
-
warn(`::warning:: [replay] cassette stale: ${s}\n`);
|
|
1939
|
-
}
|
|
1990
|
+
// Findings are surfaced UNCONDITIONALLY (class-tagged) in JSON (RunResult.staleness) even on the default
|
|
1991
|
+
// gate — a token-free consumer can distinguish "verified clean" from "couldn't verify" (the `unverifiable-*`
|
|
1992
|
+
// classes) WITHOUT the verdict changing. The `--strict` / `--fail-on-skill-drift` gates below are the ONLY
|
|
1993
|
+
// place a finding becomes a failing assertion. The single `warn()` loop is the lone stderr emitter — no
|
|
1994
|
+
// per-branch `warn()`, so a non-strict run never double-warns one cause. Uses the SHARED `computeStaleness`
|
|
1995
|
+
// (no longer a forked copy), so it inherits the per-file detail, the `debugSkillHashMismatch` hook, the
|
|
1996
|
+
// GITSET/agent-scope flip buckets, and the both-buckets attribution fix for free.
|
|
1997
|
+
const staleness = computeStaleness(cassette, opts.cassetteDir);
|
|
1998
|
+
for (const s of staleness)
|
|
1999
|
+
warn(`::warning:: [replay] cassette stale: ${s.message}\n`);
|
|
1940
2000
|
// §2.5 backward compat: warn loudly when controlOut is absent so the user knows question/gate
|
|
1941
2001
|
// assertions are being EXCLUDED (not vacuously evaluated) from this run.
|
|
1942
2002
|
if (!session.hasControlOut) {
|
|
@@ -2085,10 +2145,17 @@ export async function replayCassette(cassette, hooks = [], opts = {}) {
|
|
|
2085
2145
|
toolResultTexts: rec.toolResults.map((r) => r.assertText ?? r.text),
|
|
2086
2146
|
truncatedPaths: replayTruncatedPaths,
|
|
2087
2147
|
});
|
|
2088
|
-
// #1b: under --strict,
|
|
2148
|
+
// #1b: under --strict, EVERY staleness finding becomes a failing assertion (non-zero exit), not just a
|
|
2149
|
+
// warning. --fail-on-skill-drift is the narrower gate: only the skill-source classes fail (incl.
|
|
2150
|
+
// `unverifiable-skill` — can't verify skill staleness ⇒ not green), while baseline / format / env-level
|
|
2151
|
+
// findings stay non-failing. `else if` makes --strict the superset when both are passed.
|
|
2152
|
+
const SKILL_DRIFT_CLASSES = new Set(["skill", "shared-root", "unverifiable-skill"]);
|
|
2089
2153
|
if (opts.strict)
|
|
2090
2154
|
for (const s of staleness)
|
|
2091
|
-
assertions.push({ assertion: {}, pass: false, message: `cassette stale (--strict): ${s}` });
|
|
2155
|
+
assertions.push({ assertion: {}, pass: false, message: `cassette stale (--strict): ${s.message}` });
|
|
2156
|
+
else if (opts.failOnSkillDrift)
|
|
2157
|
+
for (const s of staleness.filter((s) => SKILL_DRIFT_CLASSES.has(s.class)))
|
|
2158
|
+
assertions.push({ assertion: {}, pass: false, message: `skill-source drift (--fail-on-skill-drift): ${s.message}` });
|
|
2092
2159
|
// future cassette version — hard failure under --strict (forward semantics may not be
|
|
2093
2160
|
// correctly interpreted here, so a green replay would be a false-green).
|
|
2094
2161
|
if (futureVersionMsg && opts.strict)
|
|
@@ -2175,6 +2242,10 @@ export async function replayCassette(cassette, hooks = [], opts = {}) {
|
|
|
2175
2242
|
subagents: rec.subagents,
|
|
2176
2243
|
unanswered: rec.unanswered,
|
|
2177
2244
|
outDir: "(replay)",
|
|
2245
|
+
// Class-tagged staleness + skip counts, surfaced to JSON callers (the gate decision already happened
|
|
2246
|
+
// above via failing assertions; these fields are pure data so a green stays green by default).
|
|
2247
|
+
staleness: staleness.length ? staleness : undefined,
|
|
2248
|
+
skippedAssertions: { full: fullSkipCount, partial: partialSkipCount },
|
|
2178
2249
|
// A cassette freezes the answer path: the replay itself is deterministic regardless of how the
|
|
2179
2250
|
// original run was answered. Always explicit (never undefined) so renderer.ts:146 treats it
|
|
2180
2251
|
// correctly — undefined would silently render as "deterministic" (#47 C1 [review-2]).
|
package/dist/run/skill-hash.js
CHANGED
|
@@ -211,8 +211,9 @@ function agentScopeEnabled() {
|
|
|
211
211
|
}
|
|
212
212
|
/** Map the first path segment under `agents/` to the skill name it would belong to: a FILE strips its
|
|
213
213
|
* extension (`agents/cap-table.md` → `cap-table`), a DIR is used as-is (`agents/cap-table/x.md` → `cap-table`).
|
|
214
|
-
* Returns null for non-`agents/` paths or a bare `agents` marker.
|
|
215
|
-
|
|
214
|
+
* Returns null for non-`agents/` paths or a bare `agents` marker. Exported so staleness attribution can
|
|
215
|
+
* classify a changed `agents/<name>.md` exactly as the hash walk does (agent-scoped → skill-private). */
|
|
216
|
+
export function agentSkillName(parts) {
|
|
216
217
|
if (parts[0] !== "agents" || parts.length < 2)
|
|
217
218
|
return null;
|
|
218
219
|
return parts.length === 2 ? parts[1].replace(/\.[^.]+$/, "") : parts[1];
|
package/docs/cassette.md
CHANGED
|
@@ -211,8 +211,9 @@ with `record --max-artifact-bytes <n>` (or `COWORK_HARNESS_MAX_ARTIFACT_BYTES`)
|
|
|
211
211
|
stays replay-checkable, and `record` fails fast if an `artifact_json` targets an artifact it had to truncate (that
|
|
212
212
|
would pass at record but fail at replay). Without a manifest (older
|
|
213
213
|
cassettes), these are skipped. A green replay re-confirms *record-time* artifacts, **not** that the current
|
|
214
|
-
skill still produces them — `replay --strict` fails the run when the `fingerprint` shows
|
|
215
|
-
|
|
214
|
+
skill still produces them — `replay --strict` fails the run when the `fingerprint` shows ANY skill/baseline
|
|
215
|
+
drift, or `replay --fail-on-skill-drift` fails only on skill-source drift (leaving baseline drift a warning).
|
|
216
|
+
Either way, every replay result also reports the drift in `staleness[]` (class-tagged) for a JSON gate to read.
|
|
216
217
|
|
|
217
218
|
### Still skipped on replay (no filesystem/network in a cassette)
|
|
218
219
|
|
|
@@ -320,9 +321,12 @@ possible.
|
|
|
320
321
|
- **`recorded under an older hash format (vN → vM)`** — format upgrade; re-record once and the message
|
|
321
322
|
goes away. (Cassettes recorded before format **v6** all need one re-record — see the boundary note below.)
|
|
322
323
|
- **`skill files changed since record — N changed (path, …)`** — the **exact** changed/added/removed file(s),
|
|
323
|
-
from the per-file manifest (`fileSigs`). For a scoped cassette
|
|
324
|
-
|
|
325
|
-
scoped skill
|
|
324
|
+
from the per-file manifest (`fileSigs`). For a scoped cassette the drift is attributed **per bucket** by the
|
|
325
|
+
actual changed paths: a `shared root changed (scope: skills/x) [N changed (…)]` message for shared-dependency
|
|
326
|
+
changes and a `skills/x changed since record [N changed (…)]` message for the scoped skill's own files. When
|
|
327
|
+
**both** buckets change you get **both** messages — a co-occurring shared change no longer masks the skill's
|
|
328
|
+
own drift. (With `COWORK_HARNESS_AGENT_SCOPE=skill`, a changed `agents/<x>.md` is attributed to skill `x`,
|
|
329
|
+
matching the hash boundary.)
|
|
326
330
|
- **`recorded in '<mode>' file-set mode, verifying in '<mode>'`** — the staleness boundary differs between
|
|
327
331
|
record and verify (e.g. recorded in a git work tree but verified from a non-repo copy); the hashes are not
|
|
328
332
|
comparable, so re-record under the same mode.
|
package/docs/scenario.md
CHANGED
|
@@ -340,7 +340,9 @@ small JSON bodies) into the cassette, and `replay` materializes that snapshot to
|
|
|
340
340
|
`artifact_json` needs the JSON body inlined (small files); a hash-only (oversized) entry still satisfies
|
|
341
341
|
`file_exists` but not `artifact_json`. Without a manifest (older cassettes), they are **skipped** (loud).
|
|
342
342
|
A green `replay` re-confirms *record-time* artifacts, **not** that the current skill still produces them —
|
|
343
|
-
that needs a live `run` (the cassette's
|
|
343
|
+
that needs a live `run` (the cassette's staleness fingerprint warns when the skill/baseline drifted; `replay
|
|
344
|
+
--strict` fails on any drift, `--fail-on-skill-drift` on skill-source drift only, and every result reports it
|
|
345
|
+
in `staleness[]` for a JSON gate).
|
|
344
346
|
|
|
345
347
|
**Egress + other filesystem** assertions (`no_delete_in_outputs`, `self_heal_ran`,
|
|
346
348
|
`transcript_no_host_path`, `egress_*`/`expect_denied`) are still **skipped** on `replay` — they only run on
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cowork-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Scriptable, CI-friendly harness for Claude Cowork's runtime contract for testing skills across scenarios — same agent, mounts, egress allowlist, permission protocol, and sandbox limitations.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/scripts/gen-schema.ts
CHANGED
|
@@ -19,14 +19,21 @@ export const SCHEMA_DIR = join(REPO_ROOT, "schema");
|
|
|
19
19
|
* is. Writer + the drift-guard test both reference this one constant. */
|
|
20
20
|
export const ASSERTION_KEYS_PATH = join(REPO_ROOT, ".claude/skills/cowork-harness/scripts/assertion-keys.json");
|
|
21
21
|
|
|
22
|
-
/** The authoritative
|
|
23
|
-
* `assert --list` reads). Generating
|
|
22
|
+
/** The authoritative key lists `scenario.py` reads — derived from the Zod schemas (the same source
|
|
23
|
+
* `assert --list` reads). Generating them keeps the linter's unknown-key checks from drifting: `keys` is
|
|
24
|
+
* the `assert:` catalog, `topLevelKeys` the scenario top-level catalog (an earlier hand-maintained copy
|
|
25
|
+
* drifted and false-flagged the valid `requires_capabilities`). `profile`/`assertions` are NOT here — they
|
|
26
|
+
* are deprecated aliases handled by scenario.py's special-cases (the Zod `Scenario` preprocess remaps
|
|
27
|
+
* `profile`; `assertions` is a hard error), so they are intentionally absent from the schema shape. */
|
|
24
28
|
export function buildAssertionKeys(): string {
|
|
25
29
|
return (
|
|
26
30
|
JSON.stringify(
|
|
27
31
|
{
|
|
28
|
-
$comment: "GENERATED from the Zod
|
|
32
|
+
$comment: "GENERATED from the Zod schemas (src/types.ts) by scripts/gen-schema.ts — do not edit; run `npm run schema`.",
|
|
29
33
|
keys: Object.keys(Assertion.shape).sort(),
|
|
34
|
+
// Every valid top-level scenario key, from the ScenarioObject strictObject shape (NOT the `Scenario`
|
|
35
|
+
// preprocess wrapper). scenario.py keeps an embedded fallback parity-tested against this.
|
|
36
|
+
topLevelKeys: Object.keys(ScenarioObject.shape).sort(),
|
|
30
37
|
// The verdict-modifier subset (no-op assertions that suppress a default-fail). scenario.py keeps a
|
|
31
38
|
// hardcoded copy parity-tested against this; see VERDICT_MODIFIER_KEYS in src/types.ts.
|
|
32
39
|
verdictModifierKeys: [...VERDICT_MODIFIER_KEYS].sort(),
|