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 +22 -0
- package/README.md +1 -1
- package/dist/run/cassette.js +150 -107
- package/dist/run/skill-hash.js +3 -2
- package/docs/cassette.md +6 -3
- package/package.json +1 -1
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.
|
|
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/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. */
|
|
@@ -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
|
|
1894
|
-
//
|
|
1895
|
-
//
|
|
1896
|
-
//
|
|
1897
|
-
//
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
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) {
|
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
|
@@ -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
|
|
325
|
-
|
|
326
|
-
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.)
|
|
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.
|
|
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",
|