cowork-harness 0.14.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/CHANGELOG.md CHANGED
@@ -6,6 +6,28 @@ 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
+
9
31
  ## [0.14.0] — 2026-06-25
10
32
 
11
33
  ### Added
package/README.md CHANGED
@@ -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.14.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.
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
 
@@ -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 an exact "what changed" SUMMARY — the actionable
428
- * upgrade over the bucket-level "something changed". Returns just the summary (no prefix/suffix), so the
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
- if (!added.length && !removed.length && !changed.length)
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
- /** B3 staleness GATE: recompute the fingerprint and report drift. Unlike `replayCassette` (which WARNS),
460
- * the gate treats an unresolvable skillHash as a failure — can't verify ⇒ not green. No fingerprint → nothing
461
- * to check (legacy cassette). */
462
- export function checkStaleness(cassette, cassetteDir) {
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 msgs = [];
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
- // Gate mode: can't verify ⇒ not green. The cassette carries a baseline-of-record but we can't load the
475
- // current one to compare — a fail, not a silent skip (baselines ship with the package, so this is rare).
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
- msgs.push("cannot load the latest baseline to verify staleness — run `cowork-harness sync` or ship baselines/ (can't verify ⇒ not green)");
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
- msgs.push(`baseline moved ${fp.baseline} → ${liveBaseline} since record — re-record`);
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
- msgs.push("skill dirs not resolvable from the cassette location — cannot verify staleness (gate fails: can't verify ⇒ not green)");
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 content diff.
488
- msgs.push(`recorded in '${recMode}' file-set mode, verifying in '${liveMode}' (COWORK_HARNESS_GITSET) — re-record under the same mode`);
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
- msgs.push(`recorded with agent-scope '${fp.agentScope ?? "off"}', verifying with '${live.agentScope ?? "off"}' (COWORK_HARNESS_AGENT_SCOPE) — re-record under the same setting`);
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 (or a hint to enable it)
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
- msgs.push(`recorded under an older hash format (v${recordedVersion} → v${CASSETTE_VERSION}) — re-record once after upgrading`);
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: bucket-level diagnosis — which component of the scoped hash changed?
505
- const scope = fp.skillScope.map((s) => `skills/${s}`).join(", ");
506
- if (live.sharedHash !== fp.sharedHash) {
507
- msgs.push(`shared root changed since record (scope: ${scope})${detail} — re-record`);
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
- msgs.push(`${scope} changed since record${detail} — re-record`);
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
- msgs.push("local skill/plugin dir contents changed since record — re-record");
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 msgs;
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. */
@@ -1890,70 +1987,16 @@ export async function replayCassette(cassette, hooks = [], opts = {}) {
1890
1987
  // unchanged (frozen-structure limit). The skill-hash recompute needs the local skill dirs to be resolvable
1891
1988
  // from the cassette's session path; when they aren't (a moved/committed cassette), we say so rather than
1892
1989
  // silently skipping.
1893
- // Findings are pushed UNCONDITIONALLY (class-tagged) so they're surfaced in JSON (RunResult.staleness) even
1894
- // on the default gate — a token-free consumer can then distinguish "verified clean" from "couldn't verify"
1895
- // (the `unverifiable-*` classes) WITHOUT the verdict changing. The `--strict` / `--fail-on-skill-drift`
1896
- // gates below are the ONLY place a finding becomes a failing assertion. The single `warn()` loop at the end
1897
- // is the lone stderr emitter — no per-branch `warn()`, so a non-strict run never double-warns one cause.
1898
- const staleness = [];
1899
- if (cassette.fingerprint) {
1900
- const fp = cassette.fingerprint;
1901
- let liveBaseline;
1902
- let baselineLoadFailed = false;
1903
- try {
1904
- liveBaseline = loadBaseline("latest").appVersion;
1905
- }
1906
- catch {
1907
- baselineLoadFailed = true;
1908
- staleness.push({
1909
- class: "unverifiable-baseline",
1910
- message: "baseline could not be loaded — cannot verify staleness (env/platform, not skill drift)",
1911
- });
1912
- }
1913
- if (!baselineLoadFailed && liveBaseline && liveBaseline !== fp.baseline)
1914
- staleness.push({
1915
- class: "baseline",
1916
- message: `baseline moved ${fp.baseline} → ${liveBaseline} since record — re-record before trusting this replay`,
1917
- });
1918
- if (fp.skillHash) {
1919
- const live = buildFingerprint(cassette.scenario.session, fp.baseline, opts.cassetteDir, cassette.scenario.skills);
1920
- if (live.skillHash === undefined) {
1921
- staleness.push({
1922
- class: "unverifiable-skill",
1923
- message: "skill dirs not resolvable from this cassette location — cannot verify skill staleness (the baseline check still applies)",
1924
- });
1925
- }
1926
- else if (live.skillHash !== fp.skillHash) {
1927
- const recordedVersion = cassette.cassetteVersion ?? 0;
1928
- if (recordedVersion < CASSETTE_VERSION) {
1929
- staleness.push({
1930
- class: "format",
1931
- message: `recorded under an older hash format (v${recordedVersion} → v${CASSETTE_VERSION}) — re-record once after upgrading`,
1932
- });
1933
- }
1934
- else if (fp.sharedHash !== undefined && live.sharedHash !== undefined) {
1935
- const scope = fp.skillScope?.length ? fp.skillScope.map((s) => `skills/${s}`).join(", ") : "skill";
1936
- if (live.sharedHash !== fp.sharedHash) {
1937
- staleness.push({
1938
- class: "shared-root",
1939
- message: `shared root changed since record (scope: ${scope}) — re-record before trusting this replay`,
1940
- });
1941
- }
1942
- else {
1943
- staleness.push({ class: "skill", message: `${scope} changed since record — re-record before trusting this replay` });
1944
- }
1945
- }
1946
- else {
1947
- staleness.push({
1948
- class: "skill",
1949
- message: "local skill/plugin dir contents changed since record — re-record before trusting this replay",
1950
- });
1951
- }
1952
- }
1953
- }
1954
- for (const s of staleness)
1955
- warn(`::warning:: [replay] cassette stale: ${s.message}\n`);
1956
- }
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`);
1957
2000
  // §2.5 backward compat: warn loudly when controlOut is absent so the user knows question/gate
1958
2001
  // assertions are being EXCLUDED (not vacuously evaluated) from this run.
1959
2002
  if (!session.hasControlOut) {
@@ -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
- function agentSkillName(parts) {
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
@@ -321,9 +321,12 @@ possible.
321
321
  - **`recorded under an older hash format (vN → vM)`** — format upgrade; re-record once and the message
322
322
  goes away. (Cassettes recorded before format **v6** all need one re-record — see the boundary note below.)
323
323
  - **`skill files changed since record — N changed (path, …)`** — the **exact** changed/added/removed file(s),
324
- from the per-file manifest (`fileSigs`). For a scoped cassette it is prefixed with the bucket diagnosis
325
- (`shared root changed (scope: skills/x) [N changed (…)]`) so you still see whether the change was in your
326
- scoped skill or a shared dependency.
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.)
327
330
  - **`recorded in '<mode>' file-set mode, verifying in '<mode>'`** — the staleness boundary differs between
328
331
  record and verify (e.g. recorded in a git work tree but verified from a non-repo copy); the hashes are not
329
332
  comparable, so re-record under the same mode.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-harness",
3
- "version": "0.14.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",