cowork-harness 2.2.0 → 2.3.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.
Files changed (33) hide show
  1. package/.claude/skills/cowork-harness/SKILL.md +31 -12
  2. package/.claude/skills/cowork-harness/references/ci-recipe.md +15 -13
  3. package/.claude/skills/cowork-harness/references/critique.md +1 -1
  4. package/.claude/skills/cowork-harness/references/fidelity-and-answers.md +1 -1
  5. package/.claude/skills/cowork-harness/references/scenario-schema.md +14 -12
  6. package/.claude/skills/cowork-harness/references/task-recipes.md +2 -2
  7. package/.claude/skills/cowork-harness/scripts/assertion-keys.json +1 -0
  8. package/.claude/skills/cowork-harness/scripts/scenario.py +8 -1
  9. package/CHANGELOG.md +236 -0
  10. package/DESIGN.md +3 -3
  11. package/README.md +41 -11
  12. package/baselines/desktop-1.37937.1.json +816 -0
  13. package/baselines/prompts/cowork-system-prompt-fingerprints.json +27 -0
  14. package/dist/assert.js +77 -0
  15. package/dist/cli.js +15 -4
  16. package/dist/run/cassette.js +255 -77
  17. package/dist/run/execute.js +59 -0
  18. package/dist/run/trace-view.js +39 -1
  19. package/dist/sync/cowork-sync.js +207 -8
  20. package/dist/types.js +39 -9
  21. package/docs/README.md +3 -0
  22. package/docs/cassette.md +36 -11
  23. package/docs/debugging.md +25 -0
  24. package/docs/fidelity-gaps.md +5 -1
  25. package/docs/maintenance.md +15 -1
  26. package/docs/scenario.md +13 -10
  27. package/examples/replays/README.md +1 -1
  28. package/examples/replays/example-multiselect-gate.cassette.json +41 -46
  29. package/examples/replays/example-pdf-skill.cassette.json +90 -90
  30. package/examples/replays/hostloop-computer-links.cassette.json +62 -68
  31. package/llms.txt +3 -1
  32. package/package.json +1 -1
  33. package/schema/scenario.schema.json +28 -9
@@ -335,6 +335,33 @@
335
335
  "working_with_user_files"
336
336
  ],
337
337
  "note": "Cowork system prompt BYTE-IDENTICAL to 1.32352.0 on both the raw and the decoded hash (38505 raw / 37811 decoded code points, 43 tags, same 10 placeholders); only the minifier-assigned constant id rotated umt -> Zlt. Raw retained locally per localRawRetention."
338
+ },
339
+ "1.34493.1": {
340
+ "sha256": "e62bd9fa1eefc153ddcbd7e56cc6406a2950965e691b96b8d79dd0dbd7032f07",
341
+ "codePoints": 38505,
342
+ "decodedSha256": "a14592804dfc6728e855d3be17eeb40cef654aeb0bc6457487f5963dbedc7fdf",
343
+ "decodedCodePoints": 37811,
344
+ "sectionTags": 43,
345
+ "constantId": "tpt",
346
+ "note": "BACK-FILLED 2026-08-26 from the retained asar backup — the entry was owed at the 1.34493.1 pass and was never written, which is why the raw was also missing from ~/cowork-agent-backup/desktop-asar/1.34493.1/ until now. Cowork system prompt BYTE-IDENTICAL to 1.32885.1 on both the raw and the decoded hash (38505 raw / 37811 decoded code points, 43 tags, same 10 placeholders); only the minifier-assigned constant id rotated Zlt -> tpt."
347
+ },
348
+ "1.37937.0": {
349
+ "sha256": "e62bd9fa1eefc153ddcbd7e56cc6406a2950965e691b96b8d79dd0dbd7032f07",
350
+ "codePoints": 38505,
351
+ "decodedSha256": "a14592804dfc6728e855d3be17eeb40cef654aeb0bc6457487f5963dbedc7fdf",
352
+ "decodedCodePoints": 37811,
353
+ "sectionTags": 43,
354
+ "constantId": "Qgt",
355
+ "note": "Cowork system prompt BYTE-IDENTICAL to 1.34493.1; only the constant id rotated tpt -> Qgt. Corroborated by a SECOND source rather than by re-running the same extractor: the retained raw files for 1.32885.1, 1.34493.1, 1.37937.0 and 1.37937.1 are byte-identical to each other on disk (cmp), so the prompt has not moved since 1.20186.0."
356
+ },
357
+ "1.37937.1": {
358
+ "sha256": "e62bd9fa1eefc153ddcbd7e56cc6406a2950965e691b96b8d79dd0dbd7032f07",
359
+ "codePoints": 38505,
360
+ "decodedSha256": "a14592804dfc6728e855d3be17eeb40cef654aeb0bc6457487f5963dbedc7fdf",
361
+ "decodedCodePoints": 37811,
362
+ "sectionTags": 43,
363
+ "constantId": "Qgt",
364
+ "note": "Desktop self-updated 1.37937.0 -> 1.37937.1 mid-pass. Prompt BYTE-IDENTICAL to 1.37937.0, same constant id Qgt. Recorded separately because provenance.asarFingerprint is the SAME 16-char value on both builds (it hashes only the cowork slices), so the baseline's own fingerprint cannot distinguish them and this file is where that distinction is kept."
338
365
  }
339
366
  },
340
367
  "subagentAppendMethod": "Independent of the single-text `versions` fingerprints above: the sub-agent environment append is a TWO-BRANCH generator selected on hostLoopMode. In the SINGLE bundle module that references buildSubagentEnvironmentPrompt AND contains both branch discriminators, slice each branch's raw backtick-template body by scanning outward from its discriminator fragment (escaped backticks are decoded to bare backticks; other escapes left intact); DECODE escapes (same decoder as decodedSha256 -- 1.32352.0's codegen escape change moved BOTH branch fingerprints while the rendered branch text was byte-identical), then normalize every ${...} interpolation to the literal token ${} ; sha256 the UTF-8 bytes; keep the first 16 hex chars. The hl (host-loop) branch is discriminated by the fragment 'on the user's machine' (anchored to the occurrence immediately preceding the vm branch, since the phrase also occurs in unrelated prose), the vm (sandbox) branch by 'exist only in the sandbox'. BOTH fingerprints are mandatory per version entry. Consumed by checkSubagentPromptFacts in src/sync/cowork-sync.ts; re-derive with readMainBundleFiles + extractSubagentBranchSlices + subagentBranchFingerprint (see docs/maintenance.md). Raw texts retained locally only (see localRawRetention).",
package/dist/assert.js CHANGED
@@ -48,6 +48,30 @@ function resolveContainedManifestPath(workRoot, p) {
48
48
  * scenario asserting any of these CANNOT take. A hand-copied list there would rot exactly the way the
49
49
  * `--out`-outside-the-repo advice it replaces did. `test/hostloop-only-keys.test.ts` pins this array
50
50
  * against the `hostloopOnly("…")` call sites by scanning this file's source. */
51
+ /** The founder-visible strings of one gate, as SEPARATE fields: its question label, then every option's
52
+ * label and description. The evidence behind `question_context`.
53
+ *
54
+ * Sourced from RunRecord.gateOptions, i.e. the ASK-TIME AskUserQuestion payload the model emitted — never
55
+ * from a tool_result. That distinction is the key's whole value: a skill's producer script typically also
56
+ * writes the same sentence into its own gate-state file, so a `tool_result_matches` on that phrase grades
57
+ * true whether or not the model ever surfaced it. This function can only ever see what was actually
58
+ * offered.
59
+ *
60
+ * RETURNS A LIST, and the evaluator tests each entry separately, so a pattern can never match text spanning
61
+ * two fields. An earlier version returned one newline-joined string and claimed the newline prevented that.
62
+ * It did not, and it was measured doing the opposite: on the committed multiselect example,
63
+ * `invoicing[\s\S]*Audit logging` stitched one option's DESCRIPTION to the next option's LABEL and passed —
64
+ * a "sentence" no one was ever shown. The neighbouring transcript keys' docs actively teach `[\s\S]` for
65
+ * spanning turns, so that is the habit an author brings to this key. Structure beats a documented caveat. */
66
+ export function gateVisibleFields(g) {
67
+ const parts = [g.question];
68
+ for (const o of g.options) {
69
+ parts.push(o.label);
70
+ if (o.description !== undefined)
71
+ parts.push(o.description);
72
+ }
73
+ return parts;
74
+ }
51
75
  /** Order-insensitive multiset equality over option labels — `question_options` with `order: "any"`.
52
76
  * A multiset, not a Set: a gate that offered the same label twice is a different offer from one that
53
77
  * offered it once, and collapsing them would green a duplicated option. */
@@ -1516,6 +1540,59 @@ function check(a, ctx) {
1516
1540
  results.push(ctx.questions.some((q) => c.re.test(q)) ? ok() : fail(`no question matched: ${a.question_asked}`));
1517
1541
  }
1518
1542
  }
1543
+ if (a.question_context !== undefined) {
1544
+ const qc = a.question_context;
1545
+ // Fail CLOSED on every "we cannot see the gates" path, exactly as question_options does: an absent or
1546
+ // partial gate payload must never satisfy a key whose whole job is to prove what a person was shown.
1547
+ if (ctx.gateOptionsMissing || ctx.gateOptions === undefined) {
1548
+ results.push(fail("evidence unavailable: gate-option evidence absent for this run — cannot evaluate question_context"));
1549
+ }
1550
+ else {
1551
+ const c = compileUserRegex(qc.matches);
1552
+ if ("error" in c) {
1553
+ results.push(fail(`question_context: bad regex "${qc.matches}": ${c.error}`));
1554
+ }
1555
+ else {
1556
+ const all = ctx.gateOptions;
1557
+ let pool = all;
1558
+ let selector = "";
1559
+ let badSelector = false;
1560
+ if (qc.when_question !== undefined) {
1561
+ const w = compileUserRegex(qc.when_question);
1562
+ if ("error" in w) {
1563
+ results.push(fail(`question_context: bad regex "${qc.when_question}": ${w.error}`));
1564
+ badSelector = true;
1565
+ }
1566
+ else {
1567
+ pool = all.filter((g) => w.re.test(g.question));
1568
+ selector = ` matching /${qc.when_question}/i`;
1569
+ }
1570
+ }
1571
+ // NOTE: deliberately NO ambiguity refusal here, unlike question_options. That key pins WHICH gate
1572
+ // offered WHICH set, so silently taking the first would make it depend on gate order. This key asks
1573
+ // whether the founder was shown a phrase at a decision point at all, for which "some selected gate's
1574
+ // payload matches" is the whole semantic — there is nothing to disambiguate. Mirroring the refusal
1575
+ // would red a multi-gate run on which the founder WAS told (measured: the deck-review fixture fires
1576
+ // 5 sub-questions across 2 gates).
1577
+ if (badSelector) {
1578
+ /* already reported */
1579
+ }
1580
+ else if (pool.length === 0) {
1581
+ results.push(fail(`question_context: no question${selector} was asked (${all.length} gate(s) recorded)`));
1582
+ }
1583
+ else {
1584
+ // The founder-visible payload, in the order it is presented: question label, then each option's
1585
+ // label and description. Joined with newlines so a regex cannot straddle two fields by accident.
1586
+ const hit = pool.find((g) => gateVisibleFields(g).some((f) => c.re.test(f)));
1587
+ results.push(hit
1588
+ ? ok(`question_context: gate ${JSON.stringify(hit.question)} showed text matching /${qc.matches}/i`)
1589
+ : fail(`question_context: no gate${selector} showed text matching /${qc.matches}/i (searched ${pool.length} gate(s): ${pool
1590
+ .map((g) => JSON.stringify(g.question))
1591
+ .join(", ")})`));
1592
+ }
1593
+ }
1594
+ }
1595
+ }
1519
1596
  if (a.question_options !== undefined) {
1520
1597
  const qo = a.question_options;
1521
1598
  // Fail CLOSED on every "we cannot see the gates" path. An empty/absent list must never satisfy a
package/dist/cli.js CHANGED
@@ -436,7 +436,7 @@ const SUBCOMMAND_USAGE = {
436
436
  // flags any physical line naming an artifact next to something that looks like a path-join call).
437
437
  trace: `usage: trace <run-id | run-dir | events.jsonl> [--view ${TRACE_VIEWS.join("|")}] [--translate-paths] [--full-results] [--output-format json]
438
438
  --view tools tool call / result rows
439
- --view questions gate lifecycle (question → answer → delivered)
439
+ --view questions gate lifecycle (question → offered options, incl. each option's description → answer → delivered)
440
440
  --view dispatches sub-agent dispatch tree + dispatch_count_max
441
441
  --view tool-durations per-tool call-count/timing table, folded from the sibling timeline.jsonl ({} when the run has no timing data)
442
442
  --view tool-errors one row per errored tool call, with the full command + full multi-line stderr (each capped at 4KB); the tools view shows only the first 120 chars
@@ -3660,7 +3660,7 @@ async function cmdVerifyRun(args) {
3660
3660
  // asserting option order with no scripted answers (`on_unanswered: first`, an LLM-decided gate, a
3661
3661
  // post-hoc check on a kept run) would silently reach the evaluator with no evidence at all. Parsed only
3662
3662
  // when the key is asserted — a full events.jsonl read is not free on a long run.
3663
- const wantsGateOptions = scenario.assert.some((a) => a.question_options !== undefined);
3663
+ const wantsGateOptions = scenario.assert.some((a) => a.question_options !== undefined || a.question_context !== undefined);
3664
3664
  const parsedGates = wantsGateOptions ? parseGatesFromEvents(join(runDir, "events.jsonl")) : undefined;
3665
3665
  // Absent file OR any unparseable frame ⇒ evidence-missing, never a partial set graded as complete:
3666
3666
  // a present-but-corrupt events.jsonl is otherwise indistinguishable from "these were all the gates".
@@ -4384,8 +4384,19 @@ function cmdDiff(args) {
4384
4384
  if (!["tools", "transcript", "artifacts", "meta", "all"].includes(view))
4385
4385
  return void fail("diff", "usage", `--view must be one of tools|transcript|artifacts|meta|all (got "${view}")`, undefined, json);
4386
4386
  const allPositionals = positionals(args, ["--output-format", "--view"]);
4387
- if (allPositionals.length !== 2)
4388
- return void fail("diff", "usage", SUBCOMMAND_USAGE.diff, undefined, json);
4387
+ if (allPositionals.length !== 2) {
4388
+ // A single positional is the common typo AND a reasonable intent ("compare this cassette to the
4389
+ // committed one"). That intent is not implementable as a default: `diff` is polymorphic over
4390
+ // baselines, run dirs and cassettes, so a one-arg form needs type dispatch plus a defined source for
4391
+ // "the committed version" (git HEAD, tracked path, inside a repo). Until that exists, say what the
4392
+ // missing operand is instead of printing bare usage at someone who gave us most of what we needed.
4393
+ const hint = allPositionals.length === 1
4394
+ ? `diff needs TWO things to compare; got one (${allPositionals[0]}). Pass the other side explicitly — ` +
4395
+ `e.g. the previously committed cassette (\`git show HEAD:<path> > /tmp/before.cassette.json\`), the ` +
4396
+ `other run dir, or the other baseline. There is no one-argument form.\n\n`
4397
+ : "";
4398
+ return void fail("diff", "usage", hint + SUBCOMMAND_USAGE.diff, undefined, json);
4399
+ }
4389
4400
  const [aName, bName] = allPositionals;
4390
4401
  const aKind = detectDiffKind(aName);
4391
4402
  const bKind = detectDiffKind(bName);