Make AI code reviews compact and evidence-aware

worktree · HEAD@28e5280 → working-tree · GPT-6 (Codex)

Reviewers can now select overlapping annotations, collapse comments, keep semantic diff colors on hover, and synchronize split scrolling through settings. The npm package installs the agent skill without Git access. Verification records and deterministic quality reports distinguish author claims, current source, stale evidence and unavailable checks. The first scanner delivery supports ESLint and configured commands; optional adapters remain deferred.

100
Explained
2732 of 2732 added lines
84%
claimed verified
sourced
158
inferred
0
stubs
0
high risk
0
todos
0
questions
0
  • npm test
    exit 0 in 5.1s
    pass
  • npm run typecheck
    exit 0 in 1.5s
    pass
  • npm run lint
    exit 0 in 2.8s
    pass
  • node tests/e2e/improvements.mjs
    exit 0 in 6.7s
    pass
  • npm run test:ui
    exit 0 in 45.1s
    pass
  • npm run build -- --webpack
    exit 0 in 10.8s
    pass
files
whymark/SKILL.md+21 2100%AGENTS.md+4 0100%CLAUDE.md+2 0100%README.md+61 0100%dist/whymark.mjs+647 48100%eslint.config.mjs+1 0100%package.json+6 4100%prompts/whymark-author.md+12 0100%spec/whymark-v1.md+69 2100%[slug]/page.tsx+4 0100%cli/help.ts+22 0100%cli/whymark.ts+17 3100%whymark/file-panel.tsx+111 90100%whymark/note-card.tsx+16 9100%whymark/pills.tsx+3 3100%whymark/review-view.tsx+52 15100%lib/view-model.ts+11 1100%whymark/parse.ts+19 1100%whymark/serialize.ts+1 0100%whymark/stats.ts+1 1100%whymark/types.ts+4 0100%whymark/validate-tree.ts+43 3100%whymark/validate.ts+9 0100%whymark/verify.ts+27 2100%e2e/viewer.mjs+2 0100%001-review-improvements/spec.md+290 0100%001-review-improvements/verification.md+67 0100%cli/quality.ts+79 0100%whymark/evidence-panel.tsx+24 0100%whymark/note-disclosure.tsx+13 0100%whymark/quality-panel.tsx+30 0100%whymark/review-settings.tsx+35 0100%whymark/use-review-preferences.ts+27 0100%quality/baseline.ts+39 0100%quality/import.ts+62 0100%quality/run.ts+96 0100%quality/types.ts+39 0100%lib/review-preferences.ts+29 0100%skill/install.ts+67 0100%whymark/evidence-node.ts+101 0100%whymark/evidence.ts+68 0100%e2e/improvements.mjs+111 0100%tests/evidence.test.ts+81 0100%fixtures/multiple-notes.whymark+43 0100%tests/improvements.test.ts+40 0100%tests/package.test.ts+28 0100%tests/quality-cli.test.ts+56 0100%tests/quality.test.ts+89 0100%tests/skill-install.test.ts+35 0100%whymark.config.json+18 0100%
Execution evidence · 159 records

Author confidence and passing claims are not measured accuracy. Records are unauthenticated; source hashes establish freshness, not who ran a command.

pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.350Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.352Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run lint
Recorded: 2026-09-17T09:14:24.354Z · 2839ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 2.8s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/bd33d3e9d252051385f8223926dc7c33d4b7c5c8341c03a626d16d8366878d3b.log · SHA-256 bd33d3e9d252051385f8223926dc7c33d4b7c5c8341c03a626d16d8366878d3b
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.357Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · npm run test:ui
Recorded: 2026-09-17T09:14:24.359Z · 45108ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 45.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/933c13f502a0eb57c1c4684e4915bc184504bc261fad969ec7f9d8297ce1d789.log · SHA-256 933c13f502a0eb57c1c4684e4915bc184504bc261fad969ec7f9d8297ce1d789
pass · imported, freshness unchecked · npm run build -- --webpack
Recorded: 2026-09-17T09:14:24.361Z · 10755ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 10.8s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/00a63c0d4e1d02285597aea6921a8d9acacc174c97affa60e49fa3dc6e07e071.log · SHA-256 00a63c0d4e1d02285597aea6921a8d9acacc174c97affa60e49fa3dc6e07e071
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.363Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.365Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.367Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.369Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.371Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.373Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.375Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.377Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.379Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.381Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.383Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.385Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.387Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.389Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.391Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.393Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.395Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.397Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.399Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.401Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.403Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.405Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.407Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.409Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.411Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.413Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.415Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.416Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.418Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.420Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.422Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.424Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.426Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.428Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.430Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run build:cli
Recorded: 2026-09-17T09:14:24.432Z · 134ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 0.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e.log · SHA-256 78a32c4b00be8f150c251616422845ecfca20c0f0761a85556b8bde3bfcb420e
pass · imported, freshness unchecked · npm run lint
Recorded: 2026-09-17T09:14:24.434Z · 2839ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 2.8s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/bd33d3e9d252051385f8223926dc7c33d4b7c5c8341c03a626d16d8366878d3b.log · SHA-256 bd33d3e9d252051385f8223926dc7c33d4b7c5c8341c03a626d16d8366878d3b
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.436Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.438Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.440Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.442Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.444Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.446Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.447Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.449Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.451Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.453Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.455Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.457Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.459Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.461Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.463Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.465Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.467Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.469Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.471Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.473Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.475Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.477Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.479Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.481Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.482Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.484Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.486Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.488Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.490Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.492Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.494Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.496Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.498Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.500Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.502Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.504Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.506Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.508Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.510Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.512Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.514Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.516Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.518Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.520Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.522Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.524Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.526Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.528Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.530Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.532Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.534Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.536Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.538Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.540Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.541Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.543Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.545Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.547Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.549Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.551Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.553Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.555Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.557Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.559Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.561Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.563Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.565Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.567Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.569Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.571Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.573Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.575Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.577Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.579Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.580Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.582Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.584Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.586Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.588Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.590Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.592Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.594Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.596Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.598Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.600Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.602Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.604Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.606Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.608Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.610Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.612Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.614Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm run test:ui
Recorded: 2026-09-17T09:14:24.616Z · 45108ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 45.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/933c13f502a0eb57c1c4684e4915bc184504bc261fad969ec7f9d8297ce1d789.log · SHA-256 933c13f502a0eb57c1c4684e4915bc184504bc261fad969ec7f9d8297ce1d789
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.618Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.620Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.622Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · npm run typecheck
Recorded: 2026-09-17T09:14:24.624Z · 1499ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 1.5s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee.log · SHA-256 c8e1ffdd0432338833d871a6e343b808199440909e94deafa18f740403ea98ee
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.626Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.628Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.630Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.632Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.634Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.636Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.638Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.639Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.641Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.643Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · node tests/e2e/improvements.mjs
Recorded: 2026-09-17T09:14:24.645Z · 6689ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 6.7s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f.log · SHA-256 7ff5052f25b690b22504e96ee09352fc10c2627cf6c0ec5362cb903b58766d3f
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.647Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.649Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.651Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.653Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.655Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.657Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.659Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae
pass · imported, freshness unchecked · npm test
Recorded: 2026-09-17T09:14:24.661Z · 5085ms · exit 0
whymark@0.2.0 · v22.23.2 · cwd .
exit 0 in 5.1s
Source: 76bff8782ca960f91def68033d6f79b15d35d992d6fb1733b219f14f1053f142
Output: .artifacts/whymark/af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae.log · SHA-256 af05fae140ddc6908d27c8e10c03914b953c7ecf15c050ff6a3c9cb4b0a01cae

Code quality · source unchecked

Recorded local checks; unauthenticated evidence. Baseline comparison unavailable. Static findings do not measure code accuracy.

  • eslint v9.39.5: pass
    Command and output

    node node_modules/eslint/bin/eslint.js . --format json --rule 'complexity: [warn, 15]' --rule 'max-depth: [warn, 4]'

    .artifacts/whymark/b533372416ab4519fb5859a36dccd21c21933aa7da0362ccffb1bd7439e12ddc.log
    SHA-256 b533372416ab4519fb5859a36dccd21c21933aa7da0362ccffb1bd7439e12ddc

  • typecheck Version 5.9.3: pass
    Command and output

    npm run typecheck

    .artifacts/whymark/0f80c6df94eee3539cd51fc9109c419630ed9af937aaac2b7f410006fc3cc98b.log
    SHA-256 0f80c6df94eee3539cd51fc9109c419630ed9af937aaac2b7f410006fc3cc98b

36 of 36 findings shown

annotations158 of 158 shown · j k to step through

.agents/skills/whymark/SKILL.md

+21 2read-only3100%
@@ -51,7 +51,8 @@npx whymark stats reviews/<slug>.whymark
5151 real result. Run it. If a claim is contradicted, fix the code or the claim
5252 before handing the review over.
5353  
54Aim for **≥80% line coverage** and zero stubs. Then tell the user to open the
54+Aim for zero stubs and meaningful coverage of decisions. Do not add filler merely
55+to reach a percentage. Then tell the user to open the
5556 review at `/r/<slug>` (`npm run dev`).
5657  
5758 ## Annotating
@@ -77,7 +78,11 @@means two annotations.
7778 `generated`, `note`.
7879  
7980 **`risk`** = blast radius if you are wrong (`low`/`medium`/`high`).
80**`confidence`** = 0..1, honest. Low confidence with a `question:` is far more
81+**`urgency`** = action priority (`info`/`normal`/`urgent`/`blocking`), independent
82+of risk. Emit it as a body field, e.g. `urgency: urgent`, before `why:` so older
83+v1 readers preserve it. Several notes can cover the same line for different reasons.
84+ 
85+**`confidence`** = 0..1, an author estimate, not measured accuracy. Low confidence with a `question:` is far more
8186 useful to a reviewer than false certainty.
8287  
8388 ### `why`
@@ -160,6 +165,20 @@line someone will reject, and a note spanning twenty lines gives them nothing to
160165 act on. When one line carries two changes and only one of them is defensible,
161166 say which part is which: that is a decision the reviewer can make in one click.
162167  
168+## Execution and quality evidence
169+ 
170+`verify --write` saves command results and source fingerprints in `evidence`
171+frontmatter, with output logs under `.artifacts/whymark/`. Record conflicts and
172+unavailable checks honestly. A matching hash establishes identity, not authenticity;
173+passing tests and lint do not prove correctness. Inspect embedded commands before
174+explicitly rerunning a review; viewing/importing must never execute them.
175+ 
176+Optional deterministic checks: `npx whymark quality init` creates local configuration.
177+Read its commands before `npx whymark quality reviews/<slug>.whymark --run --write`.
178+This first adapter supports ESLint JSON and existing check commands, with no AI.
179+Missing tools and failed baselines remain unavailable. Do not change repository
180+quality policy or suppress findings without a concrete reason and expiry.
181+ 
163182 ## Anti-patterns
164183  
165184 - Narrating the code (`what:` on every line) instead of explaining it.
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown

AGENTS.md

+4 0read-only1100%
@@ -10,5 +10,9 @@This block is written and re-added by `next dev` — verify at `node_modules/nex
1010  
1111 # whymark
1212  
13+Read `~/.vault/projects/whymark/README.md` for shared project context before work.
14+Feature requirements and delivery plans live in `specs/`; the review file format
15+remains in `spec/whymark-v1.md`.
16+ 
1317 This repository defines the whymark review format (`spec/whymark-v1.md`), its tooling
1418 (`src/lib/whymark`, `src/cli/whymark.ts`), and a viewer for it (`src/app`).
intentnormalconfidence unknown

CLAUDE.md

+2 0read-only1100%
@@ -1,1 +1,3 @@
11 @AGENTS.md
2+ 
3+@~/.vault/projects/whymark/README.md
intentnormalconfidence unknown

README.md

+61 0read-only1100%
@@ -290,3 +290,64 @@and takes none of its permissions away.
290290 Stating this in the licence is the same argument the format makes. A reader
291291 deciding whether to trust code is better served by knowing how it was produced
292292 and where the review stopped than by an unqualified assurance.
293+ 
294+## Agent skill from npm
295+ 
296+The private Git repository is not required. The npm package includes the skill,
297+format reference and a working example embedded in the skill.
298+ 
299+```sh
300+npx whymark@<version> skill install --dry-run
301+npx whymark@<version> skill install --agent codex --agent claude-code
302+# Optional: install in your user directory rather than this project
303+npx whymark@<version> skill install --agent codex --global
304+```
305+ 
306+Replace `<version>` with the published release. These commands are implemented in
307+this checkout; a new release must be published before they are available from npm.
308+The default target is project-local Codex. Identical files are unchanged; customized
309+files require `--force`. Symlink destinations are refused. Installation never
310+changes unrelated agent instruction files. `init` still means `new`, not skill installation.
311+ 
312+## Compact reviews and settings
313+ 
314+A line can carry multiple comments; its count button opens a selector for each.
315+Comments collapse independently while type, urgency, line selector and author
316+confidence stay visible. Missing confidence is shown as unknown. Settings control
317+horizontal split-pane synchronization, default disclosure and compact layout.
318+Preferences stay in this browser with a session fallback when storage is blocked.
319+ 
320+## Checks without AI
321+ 
322+```sh
323+npx whymark quality init
324+# Inspect whymark.config.json first; --run executes its local shell commands.
325+npx whymark quality reviews/change.whymark --run --write
326+npx whymark quality reviews/change.whymark --run > reviews/baseline.quality.json
327+npx whymark quality reviews/change.whymark --run --baseline reviews/baseline.quality.json --write
328+# Debounced rescans; superseded commands are cancelled. Ctrl-C stops watching.
329+npx whymark quality reviews/change.whymark --run --watch --write
330+# Import existing output without executing anything
331+npx whymark quality reviews/change.whymark --import eslint.json --tool-version 9 --write
332+```
333+ 
334+Start with the generated ESLint and TypeScript commands and adapt them to your
335+repository. The ESLint command includes configurable complexity (15) and nesting (4)
336+warnings. Run checks in full project context; the viewer can filter findings to
337+changed lines. Missing tools are unavailable, not passing. A baseline needs a clean source checkout with HEAD matching the review's base
338+(short review hashes are resolved through Git) and matching tool versions/commands;
339+create it on the base checkout with configuration already committed. Comparison separates new, existing and resolved findings, handles
340+Git-detected renames, and ignores line shifts. Use a `.quality.json` file under
341+`reviews/` or outside the checkout for snapshots, so the report does not fingerprint
342+itself. Findings have stable IDs; `suppressions` entries in configuration require
343+`id`, `reason` and a future `expires` date. Checks are advisory to repository policy;
344+the command exits nonzero for failed/unavailable checks and unsuppressed errors.
345+ 
346+`verify --write` records execution metadata, output hashes and source fingerprints.
347+The local viewer checks source freshness and execution log hashes; imported evidence
348+remains unauthenticated. “Claimed verified” is an author claim, not measured code
349+accuracy. Fingerprints exclude ignored files, `.artifacts/`, and review outputs in
350+`reviews/`; they include tests/config/lockfiles but do not cover installed dependency
351+bytes or external services. Inspect output logs before sharing. No command runs just
352+by opening a review. Optional Knip, Semgrep, audit and coverage adapters remain future
353+integrations; the current quality adapter is ESLint plus configured pass/fail checks.
intentnormalconfidence unknown

dist/whymark.mjs

+647 48read-only33100%
@@ -2,8 +2,8 @@
22 /* generated by npm run build:cli — do not edit */
33  
44 // src/cli/whymark.ts
5import { existsSync as existsSync2, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6import { dirname, join as join2, relative, resolve } from "node:path";
5+import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
6+import { dirname as dirname2, join as join5, relative as relative6, resolve as resolve6 } from "node:path";
77 import { fileURLToPath } from "node:url";
88  
99 // src/lib/whymark/parse.ts
@@ -26,6 +26,7 @@var NOTE_KINDS = [
2626 "generated",
2727 "note"
2828 ];
29+var URGENCIES = ["info", "normal", "urgent", "blocking"];
2930 var VERIFY_METHODS = [
3031 "cmd",
3132 "test",
@@ -91,6 +92,7 @@var KNOWN_FIELDS = /* @__PURE__ */ new Set([
9192 ...REPEATABLE,
9293 "kind",
9394 "risk",
95+ "urgency",
9496 "confidence",
9597 "id"
9698 ]);
@@ -350,9 +352,9 @@function parseFrontmatter(source, diagnostics) {
350352 }
351353 if (data.review && typeof data.review === "object") {
352354 const r = data.review;
353 const status = typeof r.status === "string" ? r.status : "pending";
355+ const status2 = typeof r.status === "string" ? r.status : "pending";
354356 meta.review = {
355 status: status === "approved" || status === "changes-requested" ? status : "pending",
357+ status: status2 === "approved" || status2 === "changes-requested" ? status2 : "pending",
356358 by: typeof r.by === "string" ? r.by : void 0,
357359 at: typeof r.at === "string" ? r.at : void 0
358360 };
@@ -670,6 +672,13 @@function parseNoteHeader(raw, lineNo, filePath, counter, usedIds, diagnostics) {
670672 file: filePath ?? void 0
671673 });
672674 }
675+ const urgency = URGENCIES.includes(attrs.urgency) ? attrs.urgency : void 0;
676+ if (attrs.urgency && !urgency) diagnostics.push({
677+ level: "warning",
678+ code: "urgency-unknown",
679+ message: `Unknown urgency ${attrs.urgency}; preserved as a field.`,
680+ line: lineNo
681+ });
673682 let confidence;
674683 if (attrs.confidence) {
675684 const value = Number(attrs.confidence.replace("%", ""));
@@ -704,6 +713,7 @@function parseNoteHeader(raw, lineNo, filePath, counter, usedIds, diagnostics) {
704713 selector,
705714 kind,
706715 risk,
716+ urgency,
707717 confidence,
708718 sources: [],
709719 verify: [],
@@ -712,7 +722,7 @@function parseNoteHeader(raw, lineNo, filePath, counter, usedIds, diagnostics) {
712722 questions: [],
713723 refs: [],
714724 body: "",
715 extra: {},
725+ extra: attrs.urgency && !urgency ? { urgency: [attrs.urgency] } : {},
716726 sourceLine: lineNo,
717727 file: filePath
718728 };
@@ -785,6 +795,14 @@function setField(ctx, name, value, lineNo, diagnostics) {
785795 return;
786796 }
787797 }
798+ if (key === "urgency") {
799+ if (URGENCIES.includes(value)) {
800+ note.urgency = value;
801+ ctx.lastField = null;
802+ return;
803+ }
804+ diagnostics.push({ level: "warning", code: "urgency-unknown", message: `Unknown urgency ${value}; preserved as a field.`, line: lineNo, noteId: note.id });
805+ }
788806 if (key === "kind" || key === "risk" || key === "confidence" || key === "id") {
789807 const num = Number(value.replace("%", ""));
790808 if (key === "kind" && NOTE_KINDS.includes(value)) {
@@ -834,29 +852,29 @@function appendToField(ctx, text) {
834852 const note = ctx.note;
835853 const field = ctx.lastField;
836854 if (!field) return;
837 const join3 = (existing) => existing ? `${existing} ${text}` : text;
838 if (field.name === "why") note.why = join3(note.why);
839 else if (field.name === "what") note.what = join3(note.what);
840 else if (field.name === "impact") note.impact = join3(note.impact);
855+ const join6 = (existing) => existing ? `${existing} ${text}` : text;
856+ if (field.name === "why") note.why = join6(note.why);
857+ else if (field.name === "what") note.what = join6(note.what);
858+ else if (field.name === "impact") note.impact = join6(note.impact);
841859 else if (field.name === "source") {
842860 const ref = note.sources[field.index];
843 ref.note = join3(ref.note);
861+ ref.note = join6(ref.note);
844862 ref.raw = `${ref.raw} ${text}`;
845863 } else if (field.name === "verify") {
846864 const claim = note.verify[field.index];
847 claim.comment = join3(claim.comment);
865+ claim.comment = join6(claim.comment);
848866 claim.raw = `${claim.raw} ${text}`;
849867 } else if (field.name === "alt") {
850 note.alternatives[field.index] = join3(note.alternatives[field.index]);
868+ note.alternatives[field.index] = join6(note.alternatives[field.index]);
851869 } else if (field.name === "todo") {
852 note.todos[field.index] = join3(note.todos[field.index]);
870+ note.todos[field.index] = join6(note.todos[field.index]);
853871 } else if (field.name === "question") {
854 note.questions[field.index] = join3(note.questions[field.index]);
872+ note.questions[field.index] = join6(note.questions[field.index]);
855873 } else if (field.name === "ref") {
856 note.refs[field.index] = join3(note.refs[field.index]);
874+ note.refs[field.index] = join6(note.refs[field.index]);
857875 } else if (field.name.startsWith("extra:")) {
858876 const key = field.name.slice("extra:".length);
859 note.extra[key][field.index] = join3(note.extra[key][field.index]);
877+ note.extra[key][field.index] = join6(note.extra[key][field.index]);
860878 }
861879 }
862880 function finishNote(ctx, diagnostics) {
@@ -921,7 +939,7 @@function parseVerifyClaim(raw) {
921939 } else if (/^`/.test(rest)) {
922940 method = "cmd";
923941 }
924 let status;
942+ let status2;
925943 let comment;
926944 const arrow = rest.split(ARROW_RE);
927945 if (arrow.length > 1) {
@@ -931,7 +949,7 @@function parseVerifyClaim(raw) {
931949 tail
932950 );
933951 if (statusMatch) {
934 status = normaliseStatus(statusMatch[1]);
952+ status2 = normaliseStatus(statusMatch[1]);
935953 const after = tail.slice(statusMatch[0].length).trim();
936954 comment = stripParens(after) || void 0;
937955 } else {
@@ -940,7 +958,7 @@function parseVerifyClaim(raw) {
940958 } else {
941959 const trailing = /\((pass|fail|unknown|skipped)\)\s*$/i.exec(rest);
942960 if (trailing) {
943 status = normaliseStatus(trailing[1]);
961+ status2 = normaliseStatus(trailing[1]);
944962 rest = rest.slice(0, trailing.index).trim();
945963 }
946964 }
@@ -951,11 +969,11 @@function parseVerifyClaim(raw) {
951969 detail = detail.slice(0, paren.index).trim();
952970 }
953971 detail = detail.replace(/^`+|`+$/g, "").replace(/^"|"$/g, "").trim();
954 if (!status) status = method === "none" ? "unknown" : "unknown";
972+ if (!status2) status2 = method === "none" ? "unknown" : "unknown";
955973 return {
956974 method,
957975 detail: detail || void 0,
958 status,
976+ status: status2,
959977 comment,
960978 raw: value
961979 };
@@ -1109,6 +1127,7 @@function serializeNote(note, wrap = 78) {
11091127 if (!/^n\d+$/.test(note.id)) head.push(`id=${note.id}`);
11101128 const lines = [head.join(" ")];
11111129 const field = (name, value) => lines.push(...wrapField(name, value, wrap));
1130+ if (note.urgency) field("urgency", note.urgency);
11121131 if (note.why) field("why", note.why);
11131132 if (note.what) field("what", note.what);
11141133 for (const source of note.sources) field("source", source.raw);
@@ -1565,7 +1584,7 @@function coveredLines(file) {
15651584 return { covered, verified };
15661585 }
15671586 function hasPassingVerify(note) {
1568 return note.verify.some((v) => v.status === "pass" && v.method !== "none");
1587+ return !note.verify.some((v) => v.status === "fail") && note.verify.some((v) => v.status === "pass" && v.method !== "none");
15691588 }
15701589 function addedLineNumbers(file) {
15711590 const out = [];
@@ -1660,8 +1679,67 @@function percent(value) {
16601679 }
16611680  
16621681 // src/lib/whymark/validate-tree.ts
1663import { existsSync } from "node:fs";
1664import { join } from "node:path";
1682+import { existsSync, readFileSync, realpathSync } from "node:fs";
1683+import { execFileSync as execFileSync2 } from "node:child_process";
1684+import { join, relative, resolve, sep } from "node:path";
1685+ 
1686+// src/lib/whymark/evidence.ts
1687+var hash = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
1688+var nullableString = (value) => value === null || typeof value === "string";
1689+var status = (value) => ["pass", "fail", "unknown", "skipped"].includes(String(value));
1690+function readEvidence(value) {
1691+ if (!Array.isArray(value) || value.length > 1e3) return [];
1692+ return value.filter((r) => r && typeof r === "object" && r.version === 1 && typeof r.command === "string" && r.command.length <= 1e4 && nullableString(r.noteId) && nullableString(r.file) && status(r.claimed) && ["pass", "fail", "unavailable"].includes(r.status) && typeof r.ran === "string" && Number.isFinite(Date.parse(r.ran)) && Number.isFinite(r.durationMs) && r.durationMs >= 0 && (r.exitCode === null || Number.isInteger(r.exitCode)) && (r.status !== "pass" || r.exitCode === 0) && typeof r.cwd === "string" && typeof r.tool === "string" && typeof r.runtime === "string" && (r.source === null || hash(r.source)) && (r.sourceAfter === null || hash(r.sourceAfter)) && nullableString(r.head) && nullableString(r.base) && hash(r.reviewHash) && hash(r.outputHash) && typeof r.outputArtifact === "string" && typeof r.summary === "string");
1693+}
1694+ 
1695+// src/lib/quality/import.ts
1696+var MAX_BYTES = 8 * 1024 * 1024;
1697+function parseBoundedJson(text) {
1698+ if (new TextEncoder().encode(text).length > MAX_BYTES) throw new Error("Quality report exceeds 8 MB.");
1699+ return JSON.parse(text);
1700+}
1701+function safePath(path) {
1702+ return path.length > 0 && !path.startsWith("/") && !/^[A-Za-z]:/.test(path) && !path.includes("\\") && !path.split("/").includes("..") && !/[\0-\x1f]/.test(path);
1703+}
1704+function readQuality(value) {
1705+ if (!value || typeof value !== "object") return null;
1706+ const r = value;
1707+ const hash2 = (v) => typeof v === "string" && /^[a-f0-9]{64}$/.test(v);
1708+ const nullableHash = (v) => v === null || hash2(v);
1709+ if (r.version !== 1 || typeof r.clean !== "boolean" || typeof r.ran !== "string" || !Number.isFinite(Date.parse(r.ran)) || !nullableHash(r.source) || !nullableHash(r.sourceAfter) || !hash2(r.reviewHash) || !(r.head === null || typeof r.head === "string") || !(r.base === null || typeof r.base === "string") || !["local-run", "imported"].includes(r.provenance) || !["available", "unavailable"].includes(r.comparison) || !Array.isArray(r.checks) || r.checks.length > 100 || !Array.isArray(r.findings) || r.findings.length > 2e4) return null;
1710+ if (!r.checks.every((c2) => c2 && typeof c2.id === "string" && typeof c2.version === "string" && typeof c2.command === "string" && ["pass", "fail", "unavailable"].includes(c2.status) && (c2.exitCode === null || Number.isInteger(c2.exitCode)) && hash2(c2.outputHash) && typeof c2.outputArtifact === "string" && typeof c2.detail === "string")) return null;
1711+ if (!r.findings.every((f) => f && typeof f.id === "string" && typeof f.tool === "string" && typeof f.rule === "string" && typeof f.message === "string" && ["warning", "error"].includes(f.severity) && (f.path === null || typeof f.path === "string" && safePath(f.path)) && (f.line === null || Number.isInteger(f.line) && f.line > 0) && (f.endLine === null || Number.isInteger(f.endLine) && f.endLine >= (f.line ?? 1)) && (f.helpUrl === void 0 || typeof f.helpUrl === "string" && /^https?:\/\//.test(f.helpUrl)) && ["new", "existing", "resolved", "uncompared"].includes(f.status) && (f.suppression === void 0 || f.suppression !== null && typeof f.suppression === "object" && typeof f.suppression.reason === "string" && !!f.suppression.reason.trim() && typeof f.suppression.expires === "string" && Number.isFinite(Date.parse(f.suppression.expires))))) return null;
1712+ return r;
1713+}
1714+function parseESLint(text, relativePath) {
1715+ const input = parseBoundedJson(text);
1716+ if (!Array.isArray(input) || input.length > 2e4) throw new Error("Expected an ESLint JSON array.");
1717+ const findings = [];
1718+ for (const file of input) {
1719+ if (!file || typeof file.filePath !== "string" || !Array.isArray(file.messages)) throw new Error("Malformed ESLint file result.");
1720+ const path = relativePath(file.filePath);
1721+ if (!safePath(path)) throw new Error("ESLint result path escapes the repository.");
1722+ for (const message of file.messages) {
1723+ if (!message || typeof message.message !== "string" || ![1, 2].includes(message.severity) || !(message.ruleId === null || typeof message.ruleId === "string")) throw new Error("Malformed ESLint message.");
1724+ if (message.line !== void 0 && (!Number.isInteger(message.line) || message.line < 1)) throw new Error("Invalid finding line.");
1725+ if (message.endLine !== void 0 && (!Number.isInteger(message.endLine) || message.endLine < (message.line ?? 1))) throw new Error("Invalid finding range.");
1726+ findings.push({
1727+ id: "",
1728+ tool: "eslint",
1729+ rule: message.ruleId ?? "parse-error",
1730+ severity: message.severity === 2 ? "error" : "warning",
1731+ message: message.message,
1732+ path,
1733+ line: message.line ?? null,
1734+ endLine: message.endLine ?? message.line ?? null,
1735+ status: "uncompared",
1736+ ...message.ruleId && /^[a-z-]+$/.test(message.ruleId) ? { helpUrl: `https://eslint.org/docs/latest/rules/${message.ruleId}` } : {}
1737+ });
1738+ if (findings.length > 2e4) throw new Error("Too many quality findings.");
1739+ }
1740+ }
1741+ return findings;
1742+}
16651743  
16661744 // src/lib/whymark/validate.ts
16671745 function validateDocument(doc, options = {}) {
@@ -1677,6 +1755,12 @@function validateDocument(doc, options = {}) {
16771755 message: "Document contains no file sections and no notes."
16781756 });
16791757 }
1758+ if (doc.meta.extra.evidence !== void 0 && (!Array.isArray(doc.meta.extra.evidence) || readEvidence(doc.meta.extra.evidence).length !== doc.meta.extra.evidence.length)) {
1759+ diagnostics.push({ level: "warning", code: "evidence-invalid", message: "Malformed execution evidence was preserved but cannot establish verification." });
1760+ }
1761+ if (doc.meta.extra.quality !== void 0 && !readQuality(doc.meta.extra.quality)) {
1762+ diagnostics.push({ level: "warning", code: "quality-invalid", message: "Malformed quality report was preserved but cannot establish a clean scan." });
1763+ }
16801764 if (!doc.meta.summary?.trim()) {
16811765 diagnostics.push({
16821766 level: "warning",
@@ -1830,8 +1914,140 @@function collectStaleness(doc, cwd) {
18301914 }
18311915 function validateDocumentInRepo(doc, options = {}) {
18321916 const extra = options.skipStaleness ? [] : collectStaleness(doc, options.cwd ?? process.cwd());
1917+ if (!options.skipStaleness) extra.push(...collectSourceDiagnostics(doc, options.cwd ?? process.cwd()));
18331918 return validateDocument(doc, { ...options, extraDiagnostics: extra });
18341919 }
1920+function collectSourceDiagnostics(doc, cwd) {
1921+ const diagnostics = [];
1922+ for (const note of allNotes(doc)) for (const source of note.sources) {
1923+ let problem = null;
1924+ try {
1925+ if (source.type === "file") {
1926+ const match = source.locator.match(/^(.*?)(?::(\d+)(?:-(\d+))?)?$/);
1927+ if (!match || /[*?]/.test(match[1])) continue;
1928+ const path = match[1];
1929+ if (path.startsWith("/") || path.split(/[\\/]/).includes("..")) throw new Error("reference escapes repository");
1930+ let content;
1931+ const revision = doc.meta.scope === "commit" ? doc.meta.head?.match(/[a-f0-9]{7,40}$/)?.[0] : void 0;
1932+ if (doc.meta.scope === "commit" && !revision) throw new Error("recorded commit unavailable");
1933+ if (revision) content = execFileSync2("git", ["show", `${revision}:${path}`], { cwd, encoding: "utf8", maxBuffer: 2 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
1934+ else {
1935+ const root = realpathSync(cwd);
1936+ const absolute = realpathSync(resolve(root, path));
1937+ const rel = relative(root, absolute);
1938+ if (rel === ".." || rel.startsWith(`..${sep}`)) throw new Error("reference escapes repository");
1939+ content = readFileSync(absolute, "utf8");
1940+ }
1941+ const count = content.replace(/\n$/, "").split("\n").length;
1942+ if (match[2] && (Number(match[2]) < 1 || Number(match[3] ?? match[2]) > count || Number(match[3] ?? match[2]) < Number(match[2]))) problem = "line range is outside the file";
1943+ } else if (source.type === "commit") {
1944+ if (!/^[a-f0-9]{7,40}$/.test(source.locator)) throw new Error("expected a commit hash");
1945+ execFileSync2("git", ["cat-file", "-e", `${source.locator}^{commit}`], { cwd, stdio: "ignore" });
1946+ } else if (source.type === "dep") {
1947+ const match = source.locator.match(/^(@?[^@]+)@(.+)$/);
1948+ if (!match || !/^(@[a-z0-9_.-]+\/)?[a-z0-9_.-]+$/i.test(match[1])) throw new Error("expected dependency@version");
1949+ const pkg = JSON.parse(readFileSync(join(cwd, "node_modules", match[1], "package.json"), "utf8"));
1950+ if (pkg.version !== match[2]) problem = "installed dependency version differs from the citation";
1951+ }
1952+ } catch {
1953+ problem = "reference unavailable at the reviewed location";
1954+ }
1955+ if (problem) diagnostics.push({ level: "warning", code: "source-unavailable", message: `${source.locator}: ${problem}. Locator checks do not establish that a source supports the claim.`, noteId: note.id, file: note.file ?? void 0 });
1956+ }
1957+ return diagnostics;
1958+}
1959+ 
1960+// src/lib/whymark/evidence-node.ts
1961+import { createHash } from "node:crypto";
1962+import { execFileSync as execFileSync3 } from "node:child_process";
1963+import { lstatSync, readFileSync as readFileSync2, readlinkSync, realpathSync as realpathSync2, mkdirSync, writeFileSync } from "node:fs";
1964+import { join as join2, relative as relative2, resolve as resolve2, sep as sep2 } from "node:path";
1965+var sha256 = (text) => createHash("sha256").update(text).digest("hex");
1966+function gitHead(cwd) {
1967+ try {
1968+ return execFileSync3("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
1969+ } catch {
1970+ return null;
1971+ }
1972+}
1973+function excluded(path) {
1974+ return path.startsWith("reviews/") && (path.endsWith(".whymark") || path.endsWith(".quality.json")) || path.startsWith(".artifacts/");
1975+}
1976+function cleanSource(cwd) {
1977+ try {
1978+ const entries = execFileSync3("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd, encoding: "utf8" }).split("\0").filter(Boolean);
1979+ for (let i = 0; i < entries.length; i++) {
1980+ const entry = entries[i];
1981+ if (!excluded(entry.slice(3))) return false;
1982+ if (/[RC]/.test(entry.slice(0, 2)) && !excluded(entries[++i] ?? "")) return false;
1983+ }
1984+ return true;
1985+ } catch {
1986+ return false;
1987+ }
1988+}
1989+function resolveRevision(cwd, value) {
1990+ const hash2 = value?.match(/(?:^|@)([a-f0-9]{7,40})$/)?.[1];
1991+ if (!hash2) return null;
1992+ try {
1993+ return execFileSync3("git", ["rev-parse", "--verify", `${hash2}^{commit}`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
1994+ } catch {
1995+ return null;
1996+ }
1997+}
1998+function sourceFingerprint(cwd) {
1999+ try {
2000+ const names = execFileSync3("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { cwd, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
2001+ const paths = [...new Set(names.split("\0").filter(Boolean))].sort();
2002+ if (paths.length > 5e4) return null;
2003+ const digest = createHash("sha256").update("whymark-source-v1\0").update(gitHead(cwd) ?? "unborn");
2004+ let bytes = 0;
2005+ for (const path of paths) {
2006+ if (excluded(path)) continue;
2007+ digest.update(`\0${path}\0`);
2008+ try {
2009+ const info = lstatSync(join2(cwd, path));
2010+ digest.update(String(info.mode));
2011+ if (info.isSymbolicLink()) digest.update(`link:${readlinkSync(join2(cwd, path))}`);
2012+ else if (info.isFile()) {
2013+ bytes += info.size;
2014+ if (info.size > 32 * 1024 * 1024 || bytes > 128 * 1024 * 1024) return null;
2015+ digest.update(readFileSync2(join2(cwd, path)));
2016+ } else return null;
2017+ } catch (error) {
2018+ if (error.code !== "ENOENT") return null;
2019+ digest.update("missing");
2020+ }
2021+ }
2022+ return digest.digest("hex");
2023+ } catch {
2024+ return null;
2025+ }
2026+}
2027+function reviewFingerprint(doc) {
2028+ return sha256(JSON.stringify({ base: doc.meta.base, head: doc.meta.head, files: doc.files.map((file) => ({ path: file.path, oldSha: file.oldSha, newSha: file.newSha, hunks: file.hunks.map((hunk) => ({ oldStart: hunk.oldStart, newStart: hunk.newStart, heading: hunk.heading, lines: hunk.lines })) })) }));
2029+}
2030+function writeOutput(cwd, output) {
2031+ const outputHash = sha256(output);
2032+ const outputArtifact = `.artifacts/whymark/${outputHash}.log`;
2033+ const root = realpathSync2(cwd);
2034+ const directory = join2(root, ".artifacts/whymark");
2035+ for (const part of [join2(root, ".artifacts"), directory]) {
2036+ try {
2037+ if (lstatSync(part).isSymbolicLink()) throw new Error("Evidence artifact directory is a symlink.");
2038+ } catch (error) {
2039+ if (error.code !== "ENOENT") throw error;
2040+ }
2041+ }
2042+ mkdirSync(directory, { recursive: true });
2043+ const path = join2(root, outputArtifact);
2044+ try {
2045+ writeFileSync(path, output, { flag: "wx" });
2046+ } catch (error) {
2047+ if (error.code !== "EEXIST" || lstatSync(path).isSymbolicLink() || sha256(readFileSync2(path)) !== outputHash) throw error;
2048+ }
2049+ return { outputHash, outputArtifact };
2050+}
18352051  
18362052 // src/lib/whymark/verify.ts
18372053 import { spawnSync as spawnSync2 } from "node:child_process";
@@ -1852,11 +2068,15 @@function runCommand(cmd, options = {}) {
18522068 status: result.status === 0 ? "pass" : "fail",
18532069 durationMs: Date.now() - started,
18542070 output,
2071+ unavailable: Boolean(result.error || result.status === null || result.status === 127),
18552072 timedOut: Boolean(result.error && /ETIMEDOUT|timed out/i.test(String(result.error)))
18562073 };
18572074 }
18582075 function verifyDocument(doc, options = {}) {
18592076 const results = [];
2077+ const cwd = options.cwd ?? process.cwd();
2078+ const before = options.recordEvidence ? sourceFingerprint(cwd) : null;
2079+ const recordedHead = options.recordEvidence ? gitHead(cwd) : null;
18602080 const cache = /* @__PURE__ */ new Map();
18612081 const run = (cmd) => {
18622082 const cached = cache.get(cmd);
@@ -1874,7 +2094,7 @@function verifyDocument(doc, options = {}) {
18742094 file: null,
18752095 claimed: check.status,
18762096 cmd: check.cmd,
1877 outcome: outcomeFor(check.status, result.status),
2097+ outcome: result.unavailable ? "unrunnable" : outcomeFor(check.status, result.status),
18782098 run: result
18792099 };
18802100 applyToCheck(check, result);
@@ -1906,7 +2126,7 @@function verifyDocument(doc, options = {}) {
19062126 file: note.file,
19072127 claimed: claim.status,
19082128 cmd,
1909 outcome: outcomeFor(claim.status, result.status),
2129+ outcome: result.unavailable ? "unrunnable" : outcomeFor(claim.status, result.status),
19102130 run: result
19112131 };
19122132 applyToClaim(claim, result);
@@ -1914,6 +2134,35 @@function verifyDocument(doc, options = {}) {
19142134 options.onFinish?.(claimResult);
19152135 }
19162136 }
2137+ if (options.recordEvidence) {
2138+ const after = sourceFingerprint(cwd);
2139+ const previous = readEvidence(doc.meta.extra.evidence);
2140+ const additions = results.filter((result) => result.run).map((result) => {
2141+ const run2 = result.run;
2142+ return {
2143+ version: 1,
2144+ command: result.cmd,
2145+ noteId: result.noteId,
2146+ file: result.file,
2147+ claimed: result.claimed,
2148+ status: run2.unavailable ? "unavailable" : run2.status,
2149+ ran: (/* @__PURE__ */ new Date()).toISOString(),
2150+ durationMs: run2.durationMs,
2151+ exitCode: run2.exitCode,
2152+ cwd: ".",
2153+ tool: `whymark@${options.toolVersion ?? "development"}`,
2154+ runtime: process.version,
2155+ source: before,
2156+ sourceAfter: after,
2157+ head: recordedHead,
2158+ base: doc.meta.base ?? null,
2159+ reviewHash: reviewFingerprint(doc),
2160+ ...writeOutput(cwd, run2.output),
2161+ summary: summarise(run2)
2162+ };
2163+ });
2164+ doc.meta.extra.evidence = [...previous.filter((old) => !additions.some((next) => next.command === old.command && next.noteId === old.noteId && next.file === old.file)), ...additions];
2165+ }
19172166 return results;
19182167 }
19192168 function commandOf(claim) {
@@ -1962,8 +2211,341 @@function summariseResults(results) {
19622211 };
19632212 }
19642213  
2214+// src/cli/quality.ts
2215+import { readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "node:fs";
2216+import { join as join3, relative as relative4, resolve as resolve4 } from "node:path";
2217+ 
2218+// src/lib/quality/baseline.ts
2219+function identifyFindings(findings) {
2220+ const counts = /* @__PURE__ */ new Map();
2221+ return findings.map((finding) => {
2222+ const key = JSON.stringify([finding.tool, finding.rule, finding.path, finding.message]);
2223+ const occurrence = counts.get(key) ?? 0;
2224+ counts.set(key, occurrence + 1);
2225+ return { ...finding, id: sha256(`${key}:${occurrence}`) };
2226+ });
2227+}
2228+function compareFindings(current, baseline, renames = /* @__PURE__ */ new Map()) {
2229+ const usable = baseline && baseline.clean && current.base && baseline.head && current.base.endsWith(baseline.head) && baseline.source === baseline.sourceAfter && baseline.source !== null && current.source === current.sourceAfter && current.source !== null && current.checks.every((check) => check.status !== "unavailable" && baseline.checks.some((old2) => old2.id === check.id && old2.version === check.version && old2.command === check.command && old2.status !== "unavailable"));
2230+ if (!usable) return { ...current, comparison: "unavailable", findings: identifyFindings(current.findings).map((finding) => ({ ...finding, status: "uncompared" })) };
2231+ const old = identifyFindings(baseline.findings.filter((finding) => finding.status !== "resolved").map((finding) => ({ ...finding, path: finding.path ? renames.get(finding.path) ?? finding.path : null })));
2232+ const pending = new Map(old.map((finding) => [finding.id, finding]));
2233+ const findings = identifyFindings(current.findings).map((finding) => {
2234+ const existing = pending.delete(finding.id);
2235+ return { ...finding, status: existing ? "existing" : "new" };
2236+ });
2237+ for (const finding of pending.values()) {
2238+ if (current.checks.some((check) => check.id === finding.tool)) findings.push({ ...finding, status: "resolved" });
2239+ }
2240+ return { ...current, comparison: "available", findings };
2241+}
2242+function applySuppressions(findings, suppressions, now = Date.now()) {
2243+ return findings.map((finding) => {
2244+ const match = suppressions.find((item) => item.id === finding.id && item.reason.trim() && Date.parse(item.expires) > now);
2245+ const clean = { ...finding };
2246+ delete clean.suppression;
2247+ return match ? { ...clean, suppression: { reason: match.reason, expires: match.expires } } : clean;
2248+ });
2249+}
2250+ 
2251+// src/lib/quality/run.ts
2252+import { spawn } from "node:child_process";
2253+import { execFileSync as execFileSync4 } from "node:child_process";
2254+import { readFileSync as readFileSync3, realpathSync as realpathSync3 } from "node:fs";
2255+import { relative as relative3, resolve as resolve3 } from "node:path";
2256+function readConfig(path) {
2257+ const value = parseBoundedJson(readFileSync3(path, "utf8"));
2258+ if (!value || value.version !== 1 || !Array.isArray(value.checks) || !value.checks.length || value.checks.length > 20 || !value.checks.every((c2) => c2 && typeof c2.id === "string" && /^[a-z][a-z0-9-]*$/.test(c2.id) && typeof c2.command === "string" && c2.command.length > 0 && c2.command.length < 1e4 && typeof c2.versionCommand === "string" && c2.versionCommand.length > 0 && c2.versionCommand.length < 1e4 && ["eslint", "check"].includes(c2.format)) || new Set(value.checks.map((c2) => c2.id)).size !== value.checks.length) throw new Error("Invalid whymark.config.json checks.");
2259+ if (value.suppressions !== void 0 && (!Array.isArray(value.suppressions) || !value.suppressions.every((s) => s && typeof s.id === "string" && typeof s.reason === "string" && s.reason.trim() && typeof s.expires === "string" && Number.isFinite(Date.parse(s.expires))))) throw new Error("Invalid quality suppressions.");
2260+ return value;
2261+}
2262+function runTool(command, cwd, signal, timeoutMs = 12e4) {
2263+ return new Promise((resolveResult) => {
2264+ if (signal?.aborted) {
2265+ resolveResult({ code: null, stdout: "", stderr: "Cancelled", unavailable: true });
2266+ return;
2267+ }
2268+ const child = spawn(command, { cwd, shell: true, detached: process.platform !== "win32", env: { ...process.env, CI: "1", NO_COLOR: "1" }, stdio: ["ignore", "pipe", "pipe"] });
2269+ let stdout = "";
2270+ let stderr = "";
2271+ let size = 0;
2272+ let unavailable = false;
2273+ const stop = (reason) => {
2274+ unavailable = true;
2275+ stderr += `
2276+${reason}`;
2277+ try {
2278+ if (child.pid && process.platform !== "win32") process.kill(-child.pid, "SIGKILL");
2279+ else child.kill("SIGKILL");
2280+ } catch {
2281+ }
2282+ };
2283+ const abort = () => stop("Cancelled");
2284+ signal?.addEventListener("abort", abort, { once: true });
2285+ const timeout = setTimeout(() => stop("Timed out"), timeoutMs);
2286+ child.stdout.setEncoding("utf8");
2287+ child.stderr.setEncoding("utf8");
2288+ const append = (text, error) => {
2289+ size += Buffer.byteLength(text);
2290+ if (size > 8 * 1024 * 1024) {
2291+ if (!unavailable) stop("Output exceeds 8 MB");
2292+ return;
2293+ }
2294+ if (error) stderr += text;
2295+ else stdout += text;
2296+ };
2297+ child.stdout.on("data", (text) => append(text, false));
2298+ child.stderr.on("data", (text) => append(text, true));
2299+ child.on("error", (error) => {
2300+ unavailable = true;
2301+ stderr += error.message;
2302+ });
2303+ child.on("close", (code) => {
2304+ clearTimeout(timeout);
2305+ signal?.removeEventListener("abort", abort);
2306+ resolveResult({ code, stdout, stderr, unavailable: unavailable || code === null || code === 127 });
2307+ });
2308+ });
2309+}
2310+async function scanQuality(doc, cwd, config, options = {}) {
2311+ cwd = realpathSync3(cwd);
2312+ const report = {
2313+ version: 1,
2314+ ran: (/* @__PURE__ */ new Date()).toISOString(),
2315+ source: sourceFingerprint(cwd),
2316+ sourceAfter: null,
2317+ head: gitHead(cwd),
2318+ clean: cleanSource(cwd),
2319+ base: resolveRevision(cwd, doc.meta.base),
2320+ reviewHash: reviewFingerprint(doc),
2321+ provenance: "local-run",
2322+ comparison: "unavailable",
2323+ checks: [],
2324+ findings: []
2325+ };
2326+ for (const check of config.checks) {
2327+ if (options.signal?.aborted) break;
2328+ const version = await runTool(check.versionCommand, cwd, options.signal, 1e4);
2329+ const run = await runTool(check.command, cwd, options.signal);
2330+ let unavailable = run.unavailable || version.unavailable || version.code !== 0;
2331+ let detail = run.stderr.trim().slice(0, 1e3);
2332+ if (check.format === "eslint" && !unavailable) {
2333+ if (run.code !== 0 && run.code !== 1) unavailable = true;
2334+ else try {
2335+ const findings = parseESLint(run.stdout, (path) => relative3(cwd, resolve3(cwd, path)).split("\\").join("/"));
2336+ if (run.code === 1 && !findings.length) throw new Error("ESLint failed without findings.");
2337+ report.findings.push(...findings.map((finding) => ({ ...finding, tool: check.id })));
2338+ } catch (error) {
2339+ unavailable = true;
2340+ detail = error.message;
2341+ }
2342+ }
2343+ report.checks.push({
2344+ id: check.id,
2345+ version: version.stdout.trim().slice(0, 200) || "unavailable",
2346+ command: check.command,
2347+ status: unavailable ? "unavailable" : run.code === 0 ? "pass" : "fail",
2348+ exitCode: run.code,
2349+ ...writeOutput(cwd, `${run.stdout}
2350+${run.stderr}`),
2351+ detail
2352+ });
2353+ }
2354+ report.sourceAfter = sourceFingerprint(cwd);
2355+ const renames = /* @__PURE__ */ new Map();
2356+ const base = options.baseline?.head;
2357+ if (base && /^[a-f0-9]{40}$/.test(base)) {
2358+ try {
2359+ const tokens = execFileSync4("git", ["diff", "--name-status", "-z", "--find-renames", base, "--"], { cwd, encoding: "utf8" }).split("\0");
2360+ for (let i = 0; i < tokens.length; ) {
2361+ const status2 = tokens[i++];
2362+ const old = tokens[i++];
2363+ if (status2?.startsWith("R")) renames.set(old, tokens[i++]);
2364+ }
2365+ } catch {
2366+ }
2367+ }
2368+ const compared = compareFindings(report, options.baseline ?? null, renames);
2369+ compared.findings = applySuppressions(compared.findings, config.suppressions ?? []);
2370+ return compared;
2371+}
2372+ 
2373+// src/cli/quality.ts
2374+async function qualityCommand(positionals, options) {
2375+ const cwd = repoRoot() || process.cwd();
2376+ if (positionals[0] === "init") {
2377+ const path2 = join3(cwd, "whymark.config.json");
2378+ writeFileSync2(path2, `${JSON.stringify({ version: 1, checks: [
2379+ { id: "eslint", format: "eslint", command: "node node_modules/eslint/bin/eslint.js . --format json --rule 'complexity: [warn, 15]' --rule 'max-depth: [warn, 4]'", versionCommand: "node node_modules/eslint/bin/eslint.js --version" },
2380+ { id: "typecheck", format: "check", command: "npm run typecheck", versionCommand: "node node_modules/typescript/bin/tsc --version" }
2381+ ], suppressions: [] }, null, 2)}
2382+`, { flag: "wx" });
2383+ process.stdout.write(`Created ${path2}. Review the commands before running quality --run.
2384+`);
2385+ return;
2386+ }
2387+ const path = positionals[0];
2388+ if (!path || (options.run ? 1 : 0) + (options.importPath ? 1 : 0) !== 1 || options.watch && !options.run) {
2389+ throw new Error("Use quality <review.whymark> --run or --import <eslint.json>. See whymark help quality.");
2390+ }
2391+ let baseline = null;
2392+ if (options.baseline) {
2393+ baseline = readQuality(parseBoundedJson(readFileSync4(options.baseline, "utf8")));
2394+ if (!baseline) throw new Error("Invalid baseline report.");
2395+ }
2396+ let controller = new AbortController();
2397+ let generation = 0;
2398+ const execute = async () => {
2399+ const mine = ++generation;
2400+ controller.abort();
2401+ controller = new AbortController();
2402+ const signal = controller.signal;
2403+ const text = readFileSync4(path, "utf8");
2404+ const doc = parseWhymark(text);
2405+ let report;
2406+ if (options.importPath) {
2407+ const input = readFileSync4(options.importPath, "utf8");
2408+ report = compareFindings({
2409+ version: 1,
2410+ ran: (/* @__PURE__ */ new Date()).toISOString(),
2411+ source: null,
2412+ sourceAfter: null,
2413+ head: gitHead(cwd),
2414+ clean: false,
2415+ base: doc.meta.base ?? null,
2416+ reviewHash: reviewFingerprint(doc),
2417+ provenance: "imported",
2418+ comparison: "unavailable",
2419+ checks: [{ id: "eslint", version: options.toolVersion ?? "unknown", command: "Imported ESLint JSON (not executed)", status: "unavailable", exitCode: null, ...writeOutput(cwd, input), detail: "Execution and source state were not independently checked." }],
2420+ findings: parseESLint(input, (name) => relative4(cwd, resolve4(cwd, name)).split("\\").join("/"))
2421+ }, null);
2422+ } else report = await scanQuality(doc, cwd, readConfig(options.config ?? join3(cwd, "whymark.config.json")), { baseline, signal });
2423+ if (signal.aborted || mine !== generation) return;
2424+ if (options.write) {
2425+ if (readFileSync4(path, "utf8") !== text) throw new Error("Review changed during the scan; results were not attached. Rerun against the current review.");
2426+ doc.meta.extra.quality = report;
2427+ writeFileSync2(path, serializeWhymark(doc));
2428+ }
2429+ process.stdout.write(`${JSON.stringify(report, null, 2)}
2430+`);
2431+ if (!options.watch) process.exitCode = report.checks.some((check) => check.status !== "pass") || report.findings.some((f) => f.severity === "error" && f.status !== "resolved" && !f.suppression) ? 1 : 0;
2432+ };
2433+ if (!options.watch) {
2434+ await execute();
2435+ return;
2436+ }
2437+ let previous = sourceFingerprint(cwd);
2438+ if (!previous) throw new Error("Cannot watch: repository fingerprint unavailable.");
2439+ let timer;
2440+ const launch = () => {
2441+ void execute().catch((error) => process.stderr.write(`${error.message}
2442+`));
2443+ };
2444+ launch();
2445+ const interval = setInterval(() => {
2446+ const next = sourceFingerprint(cwd);
2447+ if (next === previous) return;
2448+ previous = next;
2449+ controller.abort();
2450+ generation++;
2451+ clearTimeout(timer);
2452+ timer = setTimeout(launch, 400);
2453+ }, 1e3);
2454+ await new Promise((done) => {
2455+ const stop = () => {
2456+ clearInterval(interval);
2457+ clearTimeout(timer);
2458+ controller.abort();
2459+ generation++;
2460+ process.removeListener("SIGINT", stop);
2461+ process.removeListener("SIGTERM", stop);
2462+ done();
2463+ };
2464+ process.on("SIGINT", stop);
2465+ process.on("SIGTERM", stop);
2466+ });
2467+}
2468+ 
2469+// src/lib/skill/install.ts
2470+import { existsSync as existsSync2, lstatSync as lstatSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync5, realpathSync as realpathSync4, writeFileSync as writeFileSync3 } from "node:fs";
2471+import { dirname, join as join4, relative as relative5, resolve as resolve5, sep as sep3 } from "node:path";
2472+import { homedir } from "node:os";
2473+function installSkill(options) {
2474+ const agents = [...new Set(options.agents?.length ? options.agents : ["codex"])];
2475+ if (agents.some((agent) => !["codex", "claude-code"].includes(agent))) {
2476+ throw new Error("Supported agents: codex, claude-code.");
2477+ }
2478+ const base = realpathSync4(options.global ? options.home ?? homedir() : options.cwd);
2479+ const skill = readFileSync5(join4(options.packageRoot, ".agents/skills/whymark/SKILL.md"), "utf8").replaceAll("spec/whymark-v1.md", "references/whymark-v1.md");
2480+ const assets = [
2481+ ["SKILL.md", skill],
2482+ ["references/whymark-v1.md", readFileSync5(join4(options.packageRoot, "spec/whymark-v1.md"), "utf8")]
2483+ ];
2484+ const pending = [];
2485+ for (const agent of agents) {
2486+ const destination = join4(base, agent === "codex" ? ".agents" : ".claude", "skills/whymark");
2487+ for (const [name, content] of assets) {
2488+ const path = resolve5(destination, name);
2489+ assertNoSymlinks(base, path);
2490+ const identical = existsSync2(path) && readFileSync5(path, "utf8") === content;
2491+ if (!identical && existsSync2(path) && !options.force) {
2492+ throw new Error(`Refusing to overwrite ${path}. Use --force to replace this skill explicitly.`);
2493+ }
2494+ pending.push({ path, content, action: identical ? "unchanged" : existsSync2(path) ? "replace" : "create" });
2495+ }
2496+ }
2497+ if (!options.dryRun) {
2498+ for (const file of pending) {
2499+ if (file.action === "unchanged") continue;
2500+ assertNoSymlinks(base, file.path);
2501+ mkdirSync2(dirname(file.path), { recursive: true });
2502+ writeFileSync3(file.path, file.content, { flag: file.action === "create" ? "wx" : "w" });
2503+ }
2504+ }
2505+ return pending.map(({ path, action }) => ({ path, action }));
2506+}
2507+function assertNoSymlinks(base, path) {
2508+ const parts = relative5(base, path).split(sep3);
2509+ if (parts.includes("..")) throw new Error("Skill destination escapes its installation directory.");
2510+ let current = base;
2511+ for (const [index, part] of parts.entries()) {
2512+ current = join4(current, part);
2513+ let info;
2514+ try {
2515+ info = lstatSync2(current);
2516+ } catch (error) {
2517+ if (error.code === "ENOENT") continue;
2518+ throw error;
2519+ }
2520+ if (info.isSymbolicLink()) throw new Error(`Refusing symlink destination ${current}.`);
2521+ if (index < parts.length - 1 ? !info.isDirectory() : !info.isFile()) {
2522+ throw new Error(`Unexpected destination type at ${current}.`);
2523+ }
2524+ }
2525+}
2526+ 
19652527 // src/cli/help.ts
19662528 var TOPICS = {
2529+ quality: { aliases: [], render: () => `npx whymark quality init
2530+npx whymark quality <review.whymark> --run [--write] [--baseline report.quality.json]
2531+npx whymark quality <review.whymark> --run --watch --write
2532+npx whymark quality <review.whymark> --import eslint.json --tool-version <version> [--write]
2533+ 
2534+init writes whymark.config.json without overwriting it. Inspect its commands before
2535+--run: configured commands execute locally. Opening a review never runs them.
2536+--watch cancels superseded scans and debounces repository changes. Ctrl-C stops it.
2537+--config <path> selects trusted local configuration. --baseline compares a saved
2538+report whose head matches the review base; otherwise findings stay uncompared.
2539+JSON goes to stdout. --write also attaches results to the review. Imported ESLint
2540+JSON is evidence supplied by its author, not a fresh execution. No AI is required.
2541+` },
2542+ skill: { aliases: [], render: () => `npx whymark skill install [--agent codex|claude-code] [--global] [--dry-run] [--force]
2543+ 
2544+Installs the bundled agent skill and format reference without Git access.
2545+Default: project-local Codex skill. Repeat --agent to install both targets.
2546+--dry-run previews all paths; identical files are left alone. --force explicitly
2547+replaces customized skill files. No postinstall hooks or instruction-file edits.
2548+` },
19672549 new: { aliases: ["init"], render: helpNew },
19682550 prompt: { aliases: [], render: helpPrompt },
19692551 validate: { aliases: ["check"], render: helpValidate },
@@ -2013,6 +2595,8 @@${c2.bold("WORKFLOW")}
20132595 5. open https://whymark.x47.dev drop the file; it stays in the browser
20142596  
20152597 ${c2.bold("COMMANDS")}
2598+ quality configured local checks and ESLint findings
2599+ skill install the bundled agent skill from npm
20162600 new skeleton from a git diff ${c2.gray("alias: init")}
20172601 prompt authoring prompt with the diff embedded
20182602 validate structure, staleness, coverage ${c2.gray("alias: check")}
@@ -2381,6 +2965,19 @@function main() {
23812965 return cmdHelp(isHelpToken(args.command) ? args.positionals[0] : args.command);
23822966 }
23832967 switch (args.command) {
2968+ case "quality":
2969+ return qualityCommand(args.positionals, { run: args.has("run"), watch: args.has("watch"), write: args.has("write"), config: args.str("config"), baseline: args.str("baseline"), importPath: args.str("import"), toolVersion: args.str("tool-version") }).catch((error) => fail(error.message));
2970+ case "skill": {
2971+ if (args.positionals[0] !== "install") fail("Usage: npx whymark skill install [--agent codex|claude-code] [--global] [--dry-run] [--force]");
2972+ try {
2973+ const result = installSkill({ packageRoot: packageRoot(), cwd: process.cwd(), agents: args.all("agent"), global: args.has("global"), dryRun: args.has("dry-run"), force: args.has("force") });
2974+ for (const item of result) process.stdout.write(`${item.action} ${item.path}
2975+`);
2976+ } catch (error) {
2977+ fail(error.message);
2978+ }
2979+ return;
2980+ }
23842981 case "new":
23852982 case "init":
23862983 return cmdNew(args);
@@ -2468,11 +3065,11 @@function cmdNew(args) {
24683065 process.stdout.write(text);
24693066 return;
24703067 }
2471 const target = out ? resolve(cwd, out) : resolve(cwd, "reviews", `${slug(doc.meta.title)}.whymark`);
2472 mkdirSync(dirname(target), { recursive: true });
2473 writeFileSync(target, text);
3068+ const target = out ? resolve6(cwd, out) : resolve6(cwd, "reviews", `${slug(doc.meta.title)}.whymark`);
3069+ mkdirSync3(dirname2(target), { recursive: true });
3070+ writeFileSync4(target, text);
24743071 const stats = computeStats(doc);
2475 const rel = relative(cwd, target) || target;
3072+ const rel = relative6(cwd, target) || target;
24763073 process.stdout.write(
24773074 [
24783075 `${c.green("\u2713")} wrote ${c.bold(rel)}`,
@@ -2502,15 +3099,15 @@function cmdPrompt(args) {
25023099 if (!diff.files.length) noChanges(doc.meta.scope);
25033100 const skeleton = serializeWhymark(doc);
25043101 const pack = packageRoot();
2505 const templatePath = [join2(cwd, "prompts", "whymark-author.md"), join2(pack, "prompts", "whymark-author.md")].find(
2506 existsSync2
3102+ const templatePath = [join5(cwd, "prompts", "whymark-author.md"), join5(pack, "prompts", "whymark-author.md")].find(
3103+ existsSync3
25073104 );
2508 const template = templatePath ? stripPreamble(readFileSync(templatePath, "utf8")) : FALLBACK_PROMPT;
2509 const specInRepo = existsSync2(join2(cwd, "spec/whymark-v1.md"));
3105+ const template = templatePath ? stripPreamble(readFileSync6(templatePath, "utf8")) : FALLBACK_PROMPT;
3106+ const specInRepo = existsSync3(join5(cwd, "spec/whymark-v1.md"));
25103107 process.stdout.write(
25113108 template.replace("{{SKELETON}}", skeleton.trimEnd()).replace(
25123109 "{{SPEC_PATH}}",
2513 specInRepo ? "spec/whymark-v1.md" : "https://github.com/spink-dev/whymark/blob/main/spec/whymark-v1.md"
3110+ specInRepo ? "spec/whymark-v1.md" : join5(pack, "spec/whymark-v1.md")
25143111 )
25153112 );
25163113 }
@@ -2529,8 +3126,8 @@command you ran. Do not narrate what the code does. Keep the diff bytes untouche
25293126 \`\`\`
25303127 `;
25313128 function loadDoc(path) {
2532 if (!existsSync2(path)) fail(`No such file: ${path}`);
2533 const text = readFileSync(path, "utf8");
3129+ if (!existsSync3(path)) fail(`No such file: ${path}`);
3130+ const text = readFileSync6(path, "utf8");
25343131 return parseWhymark(text, { filename: path });
25353132 }
25363133 function cmdValidate(args) {
@@ -2575,7 +3172,7 @@function printValidation(path, result) {
25753172 }
25763173 }
25773174 process.stdout.write(
2578 ` ${bar(stats.coverage)} ${c.bold(percent(stats.coverage))} of ${stats.added} added lines annotated \xB7 ${percent(stats.verifiedCoverage)} verified \xB7 ${stats.notes} notes \xB7 ${stats.sourced} sourced / ${stats.inferenceOnly} inference-only
3175+ ` ${bar(stats.coverage)} ${c.bold(percent(stats.coverage))} of ${stats.added} added lines annotated \xB7 ${percent(stats.verifiedCoverage)} claimed verified \xB7 ${stats.notes} notes \xB7 ${stats.sourced} sourced / ${stats.inferenceOnly} inference-only
25793176 `
25803177 );
25813178 const verdict = result.ok ? c.green("passes") : `${c.red("fails")} (${result.errors} error(s), ${result.warnings} warning(s))`;
@@ -2599,6 +3196,8 @@function cmdVerify(args) {
25993196 const json = args.has("json");
26003197 const results = verifyDocument(doc, {
26013198 cwd,
3199+ recordEvidence: args.has("write"),
3200+ toolVersion: pkgVersion(),
26023201 filter: filter ? new RegExp(filter) : void 0,
26033202 onStart: (cmd) => {
26043203 if (!json) process.stdout.write(`${c.gray("\u2192 running")} ${cmd}
@@ -2622,11 +3221,11 @@${c.bold("verified")} ${summary.total} claim(s): ${c.green(`${summary.confirmed}
26223221 );
26233222 }
26243223 if (args.has("write")) {
2625 writeFileSync(path, serializeWhymark(doc));
3224+ writeFileSync4(path, serializeWhymark(doc));
26263225 if (!json) process.stdout.write(`${c.green("\u2713")} updated ${path} with real results
26273226 `);
26283227 }
2629 process.exit(summary.contradicted > 0 ? 1 : 0);
3228+ process.exit(summary.contradicted > 0 || summary.unrunnable > 0 || results.some((result) => result.run?.status === "fail") ? 1 : 0);
26303229 }
26313230 function outcomeLabel(result) {
26323231 const where = result.noteId ? c.gray(` [${result.noteId}]`) : c.gray(" [check]");
@@ -2701,7 +3300,7 @@function cmdFmt(args) {
27013300 const doc = loadDoc(path);
27023301 const text = serializeWhymark(doc);
27033302 if (args.has("write")) {
2704 writeFileSync(path, text);
3303+ writeFileSync4(path, text);
27053304 process.stdout.write(`${c.green("\u2713")} formatted ${path}
27063305 `);
27073306 } else {
@@ -2715,20 +3314,20 @@function slug(title) {
27153314 return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "review";
27163315 }
27173316 function packageRoot() {
2718 const here = dirname(fileURLToPath(import.meta.url));
2719 for (const dir of [join2(here, ".."), join2(here, "../..")]) {
2720 const pkgPath = join2(dir, "package.json");
2721 if (!existsSync2(pkgPath)) continue;
3317+ const here = dirname2(fileURLToPath(import.meta.url));
3318+ for (const dir of [join5(here, ".."), join5(here, "../..")]) {
3319+ const pkgPath = join5(dir, "package.json");
3320+ if (!existsSync3(pkgPath)) continue;
27223321 try {
2723 if (JSON.parse(readFileSync(pkgPath, "utf8")).name === "whymark") return dir;
3322+ if (JSON.parse(readFileSync6(pkgPath, "utf8")).name === "whymark") return dir;
27243323 } catch {
27253324 }
27263325 }
2727 return join2(here, "../..");
3326+ return join5(here, "../..");
27283327 }
27293328 function pkgVersion() {
27303329 try {
2731 return JSON.parse(readFileSync(join2(packageRoot(), "package.json"), "utf8")).version ?? "0.0.0";
3330+ return JSON.parse(readFileSync6(join5(packageRoot(), "package.json"), "utf8")).version ?? "0.0.0";
27323331 } catch {
27333332 return "0.0.0";
27343333 }
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown
generatednormalconfidence unknown

eslint.config.mjs

+1 0read-only1100%
@@ -11,6 +11,7 @@const eslintConfig = defineConfig([
1111 ".next/**",
1212 "out/**",
1313 "build/**",
14+ "dist/**",
1415 "next-env.d.ts",
1516 ]),
1617 ]);
intentnormalconfidence unknown

package.json

+6 4read-only4100%
@@ -1,7 +1,7 @@
11 {
22 "name": "whymark",
33 "version": "0.2.0",
4 "description": "whymark a file format and reviewer for reading AI-written code changes with the reasoning, sources, and verification attached to each line.",
4+ "description": "whymark \u2014 a file format and reviewer for reading AI-written code changes with the reasoning, sources, and verification attached to each line.",
55 "author": "Samuel Spink",
66 "license": "MIT",
77 "bin": {
@@ -13,7 +13,8 @@
1313 "prompts/whymark-author.md",
1414 "spec/whymark-v1.md",
1515 "README.md",
16 "LICENSE"
16+ "LICENSE",
17+ ".agents/skills/whymark/SKILL.md"
1718 ],
1819 "engines": {
1920 "node": "22.x"
@@ -36,7 +37,7 @@
3637 "scripts": {
3738 "dev": "next dev --port 43917",
3839 "build": "next build",
39 "build:cli": "esbuild src/cli/whymark.ts --bundle --platform=node --format=esm --outfile=dist/whymark.mjs --external:yaml --legal-comments=none --banner:js='/* generated by npm run build:cli do not edit */'",
40+ "build:cli": "esbuild src/cli/whymark.ts --bundle --platform=node --format=esm --outfile=dist/whymark.mjs --external:yaml --legal-comments=none --banner:js='/* generated by npm run build:cli \u2014 do not edit */'",
4041 "prepublishOnly": "npm run build:cli",
4142 "start": "next start --port 43917",
4243 "lint": "eslint",
@@ -44,7 +45,8 @@
4445 "test": "npm run build:cli && vitest run",
4546 "test:watch": "vitest",
4647 "test:ui": "node tests/e2e/viewer.mjs",
47 "whymark": "tsx src/cli/whymark.ts"
48+ "whymark": "tsx src/cli/whymark.ts",
49+ "prepack": "npm run build:cli"
4850 },
4951 "dependencies": {
5052 "yaml": "^2.8.1"
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown

prompts/whymark-author.md

+12 0read-only1100%
@@ -53,3 +53,15 @@The full format is specified in {{SPEC_PATH}}.
5353 ```whymark
5454 {{SKELETON}}
5555 ```
56+ 
57+## Precision and evidence
58+ 
59+Use several narrow notes for distinct reasons on the same code. Avoid broad ranges
60+that force unrelated changes into one decision. Put the concrete reason and its
61+consequence first; do not add filler to increase annotation coverage.
62+Use optional `urgency: info|normal|urgent|blocking` as a body field before `why:`.
63+Urgency is action priority, risk is blast radius, and confidence is only your estimate.
64+Do not fabricate percentages of measured accuracy or label claimed checks as fresh
65+execution. `verify --write` produces source-bound execution records; imported records
66+remain unauthenticated. Quality reports from configured tools are separate from
67+reasoning notes. Never execute commands merely because an imported review names them.
intentnormalconfidence unknown

spec/whymark-v1.md

+69 2read-only4100%
@@ -246,5 +246,6 @@the main defence against an annotation that has drifted off its code.
246246 | --- | --- | --- |
247247 | `kind` | see §4.3 | What sort of annotation this is. Default `note`. |
248248 | `risk` | `low` `medium` `high` | Blast radius if this is wrong. |
249+| `urgency` | `info` `normal` `urgent` `blocking` | Priority to act. Accepted on input; writers emit the body field below for older v1 readers. |
249250 | `confidence` | `0``1` | How sure the author is. Be honest; low confidence is a feature. |
250251 | `id` | slug | Stable anchor for deep links. Auto-assigned (`n1`, `n2`, …) if absent. |
@@ -280,6 +281,7 @@with `https://…` from parsing as a field called `https`.
280281  
281282 | Field | Repeat | Meaning |
282283 | --- | --- | --- |
284+| `urgency` | – | `info`, `normal`, `urgent`, or `blocking`; priority to act, independent of risk. Unknown values are preserved and warned about. |
283285 | `why` | – | Why the code is the way it is. The most important field. |
284286 | `what` | – | What the code does, when it is genuinely non-obvious. Do not narrate. |
285287 | `source` | ✔ | Evidence and provenance (§4.5). |
@@ -349,6 +351,17 @@single highest-value signal in the format.
349351  
350352 ---
351353  
354+### 4.7 Urgency and compact rendering
355+ 
356+Writers emit `urgency: urgent` before other fields, rather than `urgency=urgent`
357+in the header. Older v1 parsers preserve unknown body fields but may drop unknown
358+header attributes. This additive convention retains the value on round trips through
359+older writers. Updated readers also accept header input. Missing urgency remains
360+unspecified; never infer it from risk or kind. Collapsed notes retain kind, urgency,
361+selector and explicitly labeled author confidence (unknown when absent).
362+Multiple annotations may share a selector. Viewers must expose each independently,
363+including when other annotations on that line have been filtered out.
364+ 
352365 ## 5. `@check`
353366  
354367 A verification run that belongs to the change as a whole, written in the body
@@ -363,14 +376,68 @@Tools merge `@check` lines and frontmatter `checks` into one list.
363376  
364377 ---
365378  
379+### 5.1 Execution evidence extension
380+ 
381+Optional frontmatter `evidence` is an array of version-1 execution records.
382+Existing v1 tools preserve this frontmatter extension; a reader must not promote
383+legacy passing claims to fresh execution results. Each record contains:
384+ 
385+- `version: 1`, `command`, `noteId` (null for a global check), `file` (nullable),
386+ `claimed` (original claim), `status` (`pass`, `fail`, `unavailable`).
387+- `ran` (ISO time), `durationMs`, `exitCode` (nullable), `cwd` (repo relative),
388+ `tool` (whymark version), `runtime` (Node version).
389+- `source` / `sourceAfter` (nullable SHA-256 source fingerprints), `head` and
390+ `base` (nullable revisions), `reviewHash` (SHA-256 of the normalized diff).
391+- `outputHash` (SHA-256), `outputArtifact` (repo-relative output file), `summary`.
392+ 
393+`verify --write` records these alongside updated claims. Logs are saved under
394+`.artifacts/whymark/<hash>.log`; inspect logs before sharing them. Fingerprints
395+cover HEAD, tracked files (including deletions), and non-ignored untracked files,
396+including tests/config/lockfiles and symlink targets as link text. Generated
397+`.artifacts/` and review outputs under `reviews/` (`.whymark`, `.quality.json`)
398+are excluded. Ignored inputs, installed dependency bytes and external services are
399+not fingerprinted; this is bounded evidence, not a reproducible-build attestation.
400+Reviews outside `reviews/` may invalidate their own source fingerprint when rewritten.
401+ 
402+Hashes bind evidence to bytes; they do not authenticate the author or guarantee
403+truth. Imported records are labeled as such. The local viewer checks current source,
404+normalized diff identity and output hashes before showing a current record. A changed
405+source or diff is stale; missing outputs/fingerprints are unavailable. A passing and
406+failing claim on one note cannot yield unqualified passing coverage. A contradiction
407+is retained even when the original claim is rewritten with the command result.
408+Viewing/importing never executes commands. Explicit `verify` executes shell commands
409+from the review; inspect them before using it on an untrusted document.
410+ 
411+### 5.2 Quality report extension
412+ 
413+Optional frontmatter `quality` holds one version-1 report. Fields: `version`, `ran`,
414+`source`, `sourceAfter`, `head`, `clean` (whether relevant source was clean), `base`, `reviewHash`, `provenance` (`local-run` or
415+`imported`), `comparison` (`available` or `unavailable`), `checks`, and `findings`.
416+Each check has `id`, `version`, `command`, `status` (`pass`, `fail`, `unavailable`),
417+`exitCode`, `outputHash`, `outputArtifact`, and `detail`.
418+Each finding has `id`, `tool`, `rule`, `severity` (`warning` or `error`), `message`,
419+nullable repo-relative `path`, nullable one-based `line` and `endLine`, optional
420+HTTP(S) `helpUrl`, and `status` (`new`, `existing`, `resolved`, `uncompared`). Optional
421+`suppression` contains `reason` and an ISO `expires` time. Expired suppressions do
422+not suppress a new scan. IDs derive from tool/rule/path/message plus duplicate
423+occurrence, so shifted lines retain identity; identical duplicate findings can be
424+ambiguous. Rename mappings are taken from Git when a baseline is available.
425+ 
426+Quality reports are supplied evidence, never measured accuracy. Failed/missing
427+scanners and missing/mismatched baselines cannot be treated as a clean comparison.
428+Importers bound report size, validate paths/ranges and preserve project-level findings.
429+Unknown extension data remains in frontmatter; malformed known data is diagnosed
430+and must not be interpreted as successful evidence.
431+ 
366432 ## 6. Derived metrics
367433  
368434 Tools compute these; they are never stored in the file.
369435  
370436 - **Coverage** — the share of added lines (`+`) covered by at least one
371437 annotation. Uncovered added lines are code that arrived with no explanation.
372- **Verified coverage** — the share of added lines covered by an annotation with
373 at least one `verify` claim whose status is `pass`.
438+- **Claimed verified coverage** (API: `verifiedCoverage`) — the share of added lines covered by an annotation with
439+ at least one `verify` claim whose status is `pass`, with no failing claim on that note. This is author-reported
440+ coverage, not fresh execution or a correctness score.
374441 - **Sourced share** — annotations with at least one `source` that is not
375442 `inference`.
376443 - **Open items** — `todo` and `question` fields, plus `fail`/`unknown` claims.
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown

src/app/r/[slug]/page.tsx

+4 0read-only2100%
@@ -1,3 +1,4 @@
1+import { localEvidence, sourceFingerprint, reviewFingerprint } from "@/lib/whymark/evidence-node";
12 import { notFound } from "next/navigation";
23 import { loadReview, listReviews } from "@/lib/reviews";
34 import { validateDocumentInRepo } from "@/lib/whymark/validate-tree";
@@ -32,6 +33,9 @@export default async function ReviewPage({ params }: PageProps<"/r/[slug]">) {
3233 const writable = canWriteWorkingTree();
3334 const [vm, validation] = await Promise.all([
3435 buildReviewVM(review.doc, {
36+ source: writable ? sourceFingerprint(process.cwd()) : undefined,
37+ reviewHash: reviewFingerprint(review.doc),
38+ evidence: writable ? localEvidence(review.doc, process.cwd()) : undefined,
3539 isWritable: writable
3640 ? (file) => isActionable(file, hashObject(file.path, process.cwd()))
3741 : undefined,
intentnormalconfidence unknown
intentnormalconfidence unknown

src/cli/help.ts

+22 0read-only2100%
@@ -7,6 +7,26 @@export type Palette = {
77 };
88  
99 const TOPICS: Record<string, { aliases: string[]; render: (c: Palette) => string }> = {
10+ quality: { aliases: [], render: () => `npx whymark quality init
11+npx whymark quality <review.whymark> --run [--write] [--baseline report.quality.json]
12+npx whymark quality <review.whymark> --run --watch --write
13+npx whymark quality <review.whymark> --import eslint.json --tool-version <version> [--write]
14+ 
15+init writes whymark.config.json without overwriting it. Inspect its commands before
16+--run: configured commands execute locally. Opening a review never runs them.
17+--watch cancels superseded scans and debounces repository changes. Ctrl-C stops it.
18+--config <path> selects trusted local configuration. --baseline compares a saved
19+report whose head matches the review base; otherwise findings stay uncompared.
20+JSON goes to stdout. --write also attaches results to the review. Imported ESLint
21+JSON is evidence supplied by its author, not a fresh execution. No AI is required.
22+` },
23+ skill: { aliases: [], render: () => `npx whymark skill install [--agent codex|claude-code] [--global] [--dry-run] [--force]
24+ 
25+Installs the bundled agent skill and format reference without Git access.
26+Default: project-local Codex skill. Repeat --agent to install both targets.
27+--dry-run previews all paths; identical files are left alone. --force explicitly
28+replaces customized skill files. No postinstall hooks or instruction-file edits.
29+` },
1030 new: { aliases: ["init"], render: helpNew },
1131 prompt: { aliases: [], render: helpPrompt },
1232 validate: { aliases: ["check"], render: helpValidate },
@@ -62,6 +82,8 @@${c.bold("WORKFLOW")}
6282 5. open https://whymark.x47.dev drop the file; it stays in the browser
6383  
6484 ${c.bold("COMMANDS")}
85+ quality configured local checks and ESLint findings
86+ skill install the bundled agent skill from npm
6587 new skeleton from a git diff ${c.gray("alias: init")}
6688 prompt authoring prompt with the diff embedded
6789 validate structure, staleness, coverage ${c.gray("alias: check")}
intentnormalconfidence unknown
intentnormalconfidence unknown

src/cli/whymark.ts

+17 3read-only6100%
@@ -10,6 +10,8 @@import { validateDocument } from "../lib/whymark/validate";
1010 import { validateDocumentInRepo } from "../lib/whymark/validate-tree";
1111 import { summariseResults, verifyDocument, type ClaimResult } from "../lib/whymark/verify";
1212 import type { Scope } from "../lib/whymark/types";
13+import { qualityCommand } from "./quality";
14+import { installSkill } from "../lib/skill/install";
1315 import { renderHelp } from "./help";
1416  
1517 const c = colors();
@@ -116,6 +118,16 @@function main() {
116118 return cmdHelp(isHelpToken(args.command) ? args.positionals[0] : args.command);
117119 }
118120 switch (args.command) {
121+ case "quality":
122+ return qualityCommand(args.positionals, { run: args.has("run"), watch: args.has("watch"), write: args.has("write"), config: args.str("config"), baseline: args.str("baseline"), importPath: args.str("import"), toolVersion: args.str("tool-version") }).catch(error => fail((error as Error).message));
123+ case "skill": {
124+ if (args.positionals[0] !== "install") fail("Usage: npx whymark skill install [--agent codex|claude-code] [--global] [--dry-run] [--force]");
125+ try {
126+ const result = installSkill({ packageRoot: packageRoot(), cwd: process.cwd(), agents: args.all("agent"), global: args.has("global"), dryRun: args.has("dry-run"), force: args.has("force") });
127+ for (const item of result) process.stdout.write(`${item.action} ${item.path}\n`);
128+ } catch (error) { fail((error as Error).message); }
129+ return;
130+ }
119131 case "new":
120132 case "init":
121133 return cmdNew(args);
@@ -265,7 +277,7 @@function cmdPrompt(args: Args) {
265277 process.stdout.write(
266278 template.replace("{{SKELETON}}", skeleton.trimEnd()).replace(
267279 "{{SPEC_PATH}}",
268 specInRepo ? "spec/whymark-v1.md" : "https://github.com/spink-dev/whymark/blob/main/spec/whymark-v1.md",
280+ specInRepo ? "spec/whymark-v1.md" : join(pack, "spec/whymark-v1.md"),
269281 ),
270282 );
271283 }
@@ -345,7 +357,7 @@function printValidation(
345357 }
346358 process.stdout.write(
347359 ` ${bar(stats.coverage)} ${c.bold(percent(stats.coverage))} of ${stats.added} added lines annotated · ` +
348 `${percent(stats.verifiedCoverage)} verified · ${stats.notes} notes · ` +
360+ `${percent(stats.verifiedCoverage)} claimed verified · ${stats.notes} notes · ` +
349361 `${stats.sourced} sourced / ${stats.inferenceOnly} inference-only\n`,
350362 );
351363 const verdict = result.ok
@@ -372,6 +384,8 @@function cmdVerify(args: Args) {
372384  
373385 const results = verifyDocument(doc, {
374386 cwd,
387+ recordEvidence: args.has("write"),
388+ toolVersion: pkgVersion(),
375389 filter: filter ? new RegExp(filter) : undefined,
376390 onStart: (cmd) => {
377391 if (!json) process.stdout.write(`${c.gray("→ running")} ${cmd}\n`);
@@ -399,7 +413,7 @@function cmdVerify(args: Args) {
399413 if (!json) process.stdout.write(`${c.green("✓")} updated ${path} with real results\n`);
400414 }
401415  
402 process.exit(summary.contradicted > 0 ? 1 : 0);
416+ process.exit(summary.contradicted > 0 || summary.unrunnable > 0 || results.some(result => result.run?.status === "fail") ? 1 : 0);
403417 }
404418  
405419 function outcomeLabel(result: ClaimResult): string {
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown

src/components/whymark/file-panel.tsx

+111 90read-only25100%
@@ -8,6 +8,7 @@import {
88 useMemo,
99 useRef,
1010 useState,
11+ type RefObject,
1112 } from "react";
1213 import {
1314 FileDiff,
@@ -18,7 +19,7 @@import {
1819 MessageSquare,
1920 } from "lucide-react";
2021 import { cn } from "@/lib/utils";
21import { layoutRail, type Anchor } from "@/lib/whymark/align";
22+import { layoutCards, layoutRail, type Anchor } from "@/lib/whymark/align";
2223 import {
2324 NO_DECISIONS,
2425 partsOf,
@@ -88,6 +89,8 @@interface FilePanelProps {
8889 activeNoteId: string | null;
8990 onActivate: (noteId: string | null) => void;
9091 compact: boolean;
92+ syncScroll: boolean;
93+ compactRail: boolean;
9194 index: number;
9295 /** Decisions collected so far for this file. */
9396 decisions?: FileDecisions;
@@ -104,6 +107,8 @@export function FilePanel({
104107 activeNoteId,
105108 onActivate,
106109 compact,
110+ syncScroll,
111+ compactRail,
107112 index,
108113 decisions = NO_DECISIONS,
109114 onDecisions,
@@ -119,16 +124,22 @@export function FilePanel({
119124 // view would spend half the width on an empty column.
120125 const oneSided = file.status === "added" || file.status === "deleted";
121126 const effectiveMode: ViewMode =
122 mode === "split" && oneSided ? "unified" : mode;
127+ mode === "split" && (oneSided || compact) ? "unified" : mode;
123128  
124129 const rows = useMemo<DisplayRow[]>(
125130 () =>
126131 effectiveMode === "split"
127 ? toSplitRows(file.rows)
132+ ? toSplitRows(file.rows, !compactRail)
128133 : toUnifiedRows(file.rows),
129 [file.rows, effectiveMode],
134+ [file.rows, effectiveMode, compactRail],
130135 );
131136  
137+ const [selectedIds, setSelectedIds] = useState<string[]>([]);
138+ const chooseNotes = (ids: string[]) => {
139+ const visible = ids.filter(id => visibleNoteIds.has(id));
140+ setSelectedIds(visible);
141+ if (visible[0]) onActivate(visible[0]);
142+ };
132143 const anchors = useMemo(() => anchorsForRows(rows, notes), [rows, notes]);
133144 const [heights, setHeights] = useState<Record<string, number>>({});
134145 const cardRefs = useRef(new Map<string, HTMLDivElement>());
@@ -158,23 +169,17 @@export function FilePanel({
158169 return () => observer.disconnect();
159170 }, [measure, notes, compact]);
160171  
161 const rail = useMemo(
162 () =>
163 layoutRail(
164 anchors,
165 (id) => heights[id] ?? 132,
166 rows.length,
167 LINE_H,
168 CARD_GAP,
169 ),
170 [anchors, heights, rows.length],
171 );
172+ const rail = useMemo(() => {
173+ if (!compactRail) return layoutRail(anchors, id => heights[id] ?? 40, rows.length, LINE_H, CARD_GAP);
174+ const tops = layoutCards(anchors, id => heights[id] ?? 40, row => row * LINE_H, 4);
175+ return {
176+ tops, padding: new Map<number, number>(), offsets: rows.map(() => 0),
177+ height: Math.max(rows.length * LINE_H, ...anchors.map(anchor => (tops.get(anchor.noteId) ?? 0) + (heights[anchor.noteId] ?? 40))),
178+ };
179+ }, [anchors, heights, rows, compactRail]);
172180 const { tops, padding, offsets } = rail;
173181 const railHeight = rail.height;
174182  
175183 const highlighted = hovered ?? activeNoteId;
176 const highlightKind = highlighted
177 ? (file.notes.find((n) => n.id === highlighted)?.kind ?? null)
178 : null;
179184  
180185 /* ---- decisions -------------------------------------------------- */
@@ -245,9 +250,9 @@export function FilePanel({
245250 const rowProps = {
246251 notes,
247252 highlighted,
248 highlightKind,
249253 onHover: setHovered,
250254 onActivate,
255+ onChooseNotes: chooseNotes,
251256 decisions,
252257 canDecide,
253258 onToggle,
@@ -344,6 +349,17 @@export function FilePanel({
344349 </span>
345350 </header>
346351  
352+ {selectedIds.filter(id => visibleNoteIds.has(id)).length > 1 ? (
353+ <nav aria-label="Comments on selected line" className="flex flex-wrap items-center gap-2 border-b px-3 py-2 text-xs">
354+ <span>Comments on this line:</span>
355+ {notes.filter(note => selectedIds.includes(note.id)).map(note => (
356+ <button key={note.id} type="button" aria-pressed={activeNoteId === note.id} className="rounded border px-2 py-1 aria-pressed:bg-foreground/10" onClick={() => {
357+ onActivate(note.id);
358+ document.getElementById(`note-${note.id}`)?.scrollIntoView({ block: "nearest" });
359+ }}>{KIND_META[note.kind].label} · {note.selector.raw}</button>
360+ ))}
361+ </nav>
362+ ) : null}
347363 <div className="overflow-hidden rounded-b-xl">
348364 {notes.filter(
349365 (note) =>
@@ -378,7 +394,7 @@export function FilePanel({
378394 }}
379395 >
380396 {effectiveMode === "split" ? (
381 <SplitBody rows={rows} padding={padding} {...rowProps} />
397+ <SplitBody rows={rows} padding={padding} syncScroll={syncScroll} {...rowProps} />
382398 ) : (
383399 <UnifiedBody rows={rows} padding={padding} {...rowProps} />
384400 )}
@@ -400,7 +416,7 @@export function FilePanel({
400416 return (
401417 <div
402418 key={note.id}
403 className="absolute right-2 left-1 transition-[top] duration-200"
419+ className="absolute right-2 left-1"
404420 style={{ top: tops.get(note.id) ?? 0 }}
405421 >
406422 <NoteCard
@@ -442,9 +458,9 @@export function FilePanel({
442458 interface RowProps {
443459 notes: Note[];
444460 highlighted: string | null;
445 highlightKind: string | null;
446461 onHover: (id: string | null) => void;
447462 onActivate: (id: string | null) => void;
463+ onChooseNotes: (ids: string[]) => void;
448464 decisions: FileDecisions;
449465 canDecide: boolean;
450466 onToggle: (row: RowVM) => void;
@@ -486,33 +502,69 @@function UnifiedBody({
486502 function SplitBody({
487503 rows,
488504 padding,
505+ syncScroll,
489506 ...rowProps
490}: RowProps & { rows: DisplayRow[]; padding: Map<number, number> }) {
507+}: RowProps & { rows: DisplayRow[]; padding: Map<number, number>; syncScroll: boolean }) {
508+ const left = useRef<HTMLDivElement>(null);
509+ const right = useRef<HTMLDivElement>(null);
510+ const last = useRef<"del" | "add">("del");
511+ useEffect(() => {
512+ const a = left.current;
513+ const b = right.current;
514+ if (!a || !b) return;
515+ const expected = new WeakMap<HTMLElement, number>();
516+ const align = (source: HTMLElement, target: HTMLElement) => {
517+ const next = Math.min(source.scrollLeft, Math.max(0, target.scrollWidth - target.clientWidth));
518+ if (Math.abs(target.scrollLeft - next) < 0.5) return;
519+ expected.set(target, next);
520+ target.scrollLeft = next;
521+ };
522+ const scroll = (source: HTMLElement, target: HTMLElement, side: "del" | "add") => {
523+ const pending = expected.get(source);
524+ expected.delete(source);
525+ if (pending !== undefined && Math.abs(source.scrollLeft - pending) < 0.5) return;
526+ last.current = side;
527+ if (syncScroll) align(source, target);
528+ };
529+ const onLeft = () => scroll(a, b, "del");
530+ const onRight = () => scroll(b, a, "add");
531+ const resize = () => {
532+ if (syncScroll) align(last.current === "del" ? a : b, last.current === "del" ? b : a);
533+ };
534+ a.addEventListener("scroll", onLeft, { passive: true });
535+ b.addEventListener("scroll", onRight, { passive: true });
536+ const observer = new ResizeObserver(resize);
537+ observer.observe(a); observer.observe(b);
538+ resize();
539+ return () => { a.removeEventListener("scroll", onLeft); b.removeEventListener("scroll", onRight); observer.disconnect(); };
540+ }, [syncScroll]);
491541 return (
492542 <div
493543 data-whymark-body="split"
494544 className="grid border-r border-border/40"
495545 style={{ gridTemplateColumns: "minmax(0,1fr) 1px minmax(0,1fr)" }}
496546 >
497 <SplitColumn side="del" rows={rows} padding={padding} {...rowProps} />
547+ <SplitColumn paneRef={left} side="del" rows={rows} padding={padding} {...rowProps} />
498548 <span className="bg-border/60" aria-hidden />
499 <SplitColumn side="add" rows={rows} padding={padding} {...rowProps} />
549+ <SplitColumn paneRef={right} side="add" rows={rows} padding={padding} {...rowProps} />
500550 </div>
501551 );
502552 }
503553  
504554 function SplitColumn({
555+ paneRef,
505556 side,
506557 rows,
507558 padding,
508559 ...rowProps
509560 }: RowProps & {
561+ paneRef: RefObject<HTMLDivElement | null>;
510562 side: "add" | "del";
511563 rows: DisplayRow[];
512564 padding: Map<number, number>;
513565 }) {
514566 return (
515 <div data-whymark-side={side} className="whymark-scroll min-w-0 overflow-x-auto">
567+ <div ref={paneRef} data-whymark-side={side} className="whymark-scroll min-w-0 overflow-x-auto">
516568 <div className="whymark-code w-max min-w-full">
517569 {rows.map((row, index) => (
518570 <Fragment key={row.key}>
@@ -566,18 +618,6 @@function HunkRow({
566618 );
567619 }
568620  
569function rowTint(
570 isHighlighted: boolean,
571 highlightKind: string | null,
572 fallback?: string,
573): string | undefined {
574 if (isHighlighted && highlightKind) {
575 return `color-mix(in oklch, ${
576 KIND_META[highlightKind as keyof typeof KIND_META].color
577 } 13%, transparent)`;
578 }
579 return fallback;
580}
581621  
582622 function verdictStyle(verdict: LineVerdict): {
583623 className?: string;
@@ -600,9 +640,8 @@function UnifiedRow({
600640 row,
601641 notes,
602642 highlighted,
603 highlightKind,
604643 onHover,
605 onActivate,
644+ onChooseNotes,
606645 decisions,
607646 canDecide,
608647 onToggle,
@@ -629,11 +668,12 @@function UnifiedRow({
629668 }
630669  
631670 const line = row.row!;
632 const covered = row.noteIds.length > 0;
671+ const noteIds = notes.filter(note => row.noteIds.includes(note.id)).map(note => note.id);
672+ const covered = noteIds.length > 0;
633673 const isHighlighted =
634 highlighted !== null && row.noteIds.includes(highlighted);
674+ highlighted !== null && noteIds.includes(highlighted);
635675 const accentKind = covered
636 ? (notes.find((n) => n.id === row.noteIds[0])?.kind ?? "note")
676+ ? (notes.find((n) => n.id === (isHighlighted ? highlighted : noteIds[0]))?.kind ?? "note")
637677 : null;
638678 const accent = accentKind ? KIND_META[accentKind].color : null;
639679 const verdict = verdictFor(line, decisions);
@@ -649,12 +689,15 @@function UnifiedRow({
649689 return (
650690 <div
651691 className={cn("whymark-row group/row flex", covered && "cursor-pointer")}
652 data-notes={row.noteIds.join(" ") || undefined}
653 onMouseEnter={() => row.noteIds.length && onHover(row.noteIds[0])}
692+ data-notes={noteIds.join(" ") || undefined}
693+ data-highlighted={isHighlighted || undefined}
694+ onMouseEnter={() => noteIds.length && onHover(noteIds.includes(highlighted ?? "") ? highlighted : noteIds[0])}
654695 onMouseLeave={() => onHover(null)}
655 onClick={() => row.noteIds.length && onActivate(row.noteIds[0])}
696+ onClick={() => noteIds.length && onChooseNotes(noteIds)}
656697 style={{
657 backgroundColor: rowTint(isHighlighted, highlightKind, marker.bg),
698+ backgroundColor: marker.bg,
699+ outline: isHighlighted ? "1px solid var(--foreground)" : undefined,
700+ outlineOffset: -1,
658701 boxShadow: decorate.boxShadow,
659702 }}
660703 >
@@ -670,6 +713,7 @@function UnifiedRow({
670713 <span className="whymark-gutter w-11 shrink-0 pr-2 text-right text-[11px] tabular-nums">
671714 {line.newLine ?? ""}
672715 </span>
716+ <button type="button" disabled={!covered} aria-label={`${noteIds.length} comments on line ${line.newLine ?? line.oldLine}`} onClick={event => { event.stopPropagation(); onChooseNotes(noteIds); }} className="w-5 shrink-0 text-[10px] text-muted-foreground disabled:invisible hover:bg-foreground/10">{noteIds.length}</button>
673717 <span
674718 className="w-[3px] shrink-0"
675719 style={{ backgroundColor: accent ?? "transparent" }}
@@ -705,9 +749,8 @@function SplitRow({
705749 side,
706750 notes,
707751 highlighted,
708 highlightKind,
709752 onHover,
710 onActivate,
753+ onChooseNotes,
711754 decisions,
712755 canDecide,
713756 onToggle,
@@ -734,10 +777,11 @@function SplitRow({
734777 }
735778  
736779 const cell = side === "add" ? row.right : row.left;
737 const covered = row.noteIds.length > 0;
780+ const noteIds = notes.filter(note => cell?.noteIds.includes(note.id)).map(note => note.id);
781+ const covered = noteIds.length > 0;
738782 const isHighlighted =
739 highlighted !== null && row.noteIds.includes(highlighted);
783+ highlighted !== null && noteIds.includes(highlighted);
740784 const accentKind = covered
741 ? (notes.find((n) => n.id === row.noteIds[0])?.kind ?? "note")
785+ ? (notes.find((n) => n.id === (isHighlighted ? highlighted : noteIds[0]))?.kind ?? "note")
742786 : null;
743787 const accent = accentKind ? KIND_META[accentKind].color : null;
@@ -746,8 +790,9 @@function SplitRow({
746790 return (
747791 <div
748792 className="whymark-row flex bg-foreground/[0.02]"
749 data-notes={row.noteIds.join(" ") || undefined}
750 style={{ backgroundColor: rowTint(isHighlighted, highlightKind) }}
793+ data-notes={noteIds.join(" ") || undefined}
794+ data-highlighted={isHighlighted || undefined}
795+ style={{ outline: isHighlighted ? "1px solid var(--foreground)" : undefined, outlineOffset: -1 }}
751796 >
752797 <span className="w-5 shrink-0" aria-hidden />
753798 <span className="whymark-gutter w-11 shrink-0" />
@@ -762,20 +807,15 @@function SplitRow({
762807 return (
763808 <div
764809 className={cn("whymark-row group/row flex", covered && "cursor-pointer")}
765 data-notes={row.noteIds.join(" ") || undefined}
766 onMouseEnter={() => row.noteIds.length && onHover(row.noteIds[0])}
810+ data-notes={noteIds.join(" ") || undefined}
811+ data-highlighted={isHighlighted || undefined}
812+ onMouseEnter={() => noteIds.length && onHover(noteIds.includes(highlighted ?? "") ? highlighted : noteIds[0])}
767813 onMouseLeave={() => onHover(null)}
768 onClick={() => row.noteIds.length && onActivate(row.noteIds[0])}
814+ onClick={() => noteIds.length && onChooseNotes(noteIds)}
769815 style={{
770 backgroundColor: rowTint(
771 isHighlighted,
772 highlightKind,
773 changed
774 ? side === "add"
775 ? "var(--whymark-add-bg)"
776 : "var(--whymark-del-bg)"
777 : undefined,
778 ),
816+ backgroundColor: changed ? (side === "add" ? "var(--whymark-add-bg)" : "var(--whymark-del-bg)") : undefined,
817+ outline: isHighlighted ? "1px solid var(--foreground)" : undefined,
818+ outlineOffset: -1,
779819 boxShadow: decorate.boxShadow,
780820 }}
781821 >
@@ -788,6 +828,7 @@function SplitRow({
788828 <span className="whymark-gutter w-11 shrink-0 pr-2 text-right text-[11px] tabular-nums">
789829 {side === "add" ? (cell.newLine ?? "") : (cell.oldLine ?? "")}
790830 </span>
831+ <button type="button" disabled={!covered} aria-label={`${noteIds.length} comments on line ${side === "add" ? cell.newLine : cell.oldLine}`} onClick={event => { event.stopPropagation(); onChooseNotes(noteIds); }} className="w-5 shrink-0 text-[10px] text-muted-foreground disabled:invisible hover:bg-foreground/10">{noteIds.length}</button>
791832 <span
792833 className="w-[3px] shrink-0"
793834 style={{ backgroundColor: accent ?? "transparent" }}
@@ -1014,7 +1055,7 @@function toUnifiedRows(rows: RowVM[]): DisplayRow[] {
10141055 }));
10151056 }
10161057  
1017function toSplitRows(rows: RowVM[]): DisplayRow[] {
1058+function toSplitRows(rows: RowVM[], alignCards: boolean): DisplayRow[] {
10181059 const out: DisplayRow[] = [];
10191060 let i = 0;
10201061 let pair = 0;
@@ -1051,24 +1092,9 @@function toSplitRows(rows: RowVM[]): DisplayRow[] {
10511092 for (let k = 0; k < height; k++) {
10521093 const left = dels[k] ?? null;
10531094 const right = adds[k] ?? null;
1054 // Two annotations that land on one row cannot both sit level with it, and
1055 // one of them ends up adrift from the line it explains. Lines carrying
1056 // different notes therefore get a row each, still in their own column.
1057 if (left && right && !sameNotes(left.noteIds, right.noteIds)) {
1058 out.push({
1059 key: `p${pair++}`,
1060 kind: "pair",
1061 noteIds: left.noteIds,
1062 left,
1063 right: null,
1064 });
1065 out.push({
1066 key: `p${pair++}`,
1067 kind: "pair",
1068 noteIds: right.noteIds,
1069 left: null,
1070 right,
1071 });
1095+ if (alignCards && left?.noteIds.length && right?.noteIds.length && (left.noteIds.length !== right.noteIds.length || left.noteIds.some(id => !right.noteIds.includes(id)))) {
1096+ out.push({ key: `p${pair++}`, kind: "pair", noteIds: left.noteIds, left, right: null });
1097+ out.push({ key: `p${pair++}`, kind: "pair", noteIds: right.noteIds, left: null, right });
10721098 continue;
10731099 }
10741100 out.push({
@@ -1084,11 +1110,6 @@function toSplitRows(rows: RowVM[]): DisplayRow[] {
10841110 return out;
10851111 }
10861112  
1087function sameNotes(a: string[], b: string[]): boolean {
1088 if (!a.length || !b.length) return true;
1089 return a.length === b.length && a.every((id) => b.includes(id));
1090}
1091 
10921113 function anchorsForRows(rows: DisplayRow[], notes: Note[]): Anchor[] {
10931114 const first = new Map<string, number>();
10941115 const last = new Map<string, number>();
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown

src/components/whymark/note-card.tsx

+16 9read-only5100%
@@ -1,9 +1,10 @@
11 "use client";
22  
33 import { forwardRef } from "react";
4import { CircleHelp, ListTodo, Shuffle, Waypoints } from "lucide-react";
4+import { ChevronDown, ChevronRight, CircleHelp, ListTodo, Shuffle, Waypoints } from "lucide-react";
55 import { cn } from "@/lib/utils";
66 import type { Note } from "@/lib/whymark/types";
7+import { useNoteDisclosure } from "./note-disclosure";
78 import { KIND_META } from "./meta";
89 import { Markdown, inline } from "./markdown";
910 import { Confidence, RiskPill, SourceRow, VerifyRow } from "./pills";
@@ -22,5 +23,6 @@export const NoteCard = forwardRef<HTMLDivElement, NoteCardProps>(function NoteC
2223 { note, active, dimmed, onHover, onSelect, scopeLabel },
2324 ref,
2425 ) {
26+ const { expanded, toggle } = useNoteDisclosure(note.id);
2527 const kind = KIND_META[note.kind] ?? KIND_META.note;
2628 const Icon = kind.icon;
@@ -31,13 +33,14 @@export const NoteCard = forwardRef<HTMLDivElement, NoteCardProps>(function NoteC
3133 id={`note-${note.id}`}
3234 onMouseEnter={() => onHover?.(note.id)}
3335 onMouseLeave={() => onHover?.(null)}
34 onClick={() => onSelect?.(note.id)}
36+ onFocus={() => onHover?.(note.id)}
37+ onBlur={() => onHover?.(null)}
3538 className={cn(
3639 "group cursor-default rounded-lg border bg-card/80 shadow-sm backdrop-blur-[2px] transition-all",
3740 active
3841 ? "border-transparent ring-1 shadow-md"
3942 : "border-border/60 hover:border-border",
40 dimmed && "opacity-35",
43+ dimmed && "opacity-65",
4144 )}
4245 style={{
4346 borderLeft: `2px solid ${kind.color}`,
@@ -49,7 +52,10 @@export const NoteCard = forwardRef<HTMLDivElement, NoteCardProps>(function NoteC
4952 : {}),
5053 }}
5154 >
52 <div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2.5 pt-2">
55+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 py-1.5">
56+ <button type="button" aria-label={`${expanded ? "Collapse" : "Expand"} comment ${note.id}`} aria-expanded={expanded} aria-controls={`comment-body-${note.id}`} onClick={toggle} className="rounded p-0.5 hover:bg-foreground/10">
57+ {expanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
58+ </button>
5359 <span
5460 className="inline-flex items-center gap-1 text-[11px] font-medium"
5561 style={{ color: kind.color }}
@@ -58,16 +64,17 @@export const NoteCard = forwardRef<HTMLDivElement, NoteCardProps>(function NoteC
5864 <Icon className="size-3" />
5965 {kind.label}
6066 </span>
61 <span className="font-mono text-[11px] text-muted-foreground">
67+ <button type="button" onClick={() => onSelect?.(note.id)} className="font-mono text-[11px] text-muted-foreground hover:underline" aria-label={`Select comment ${note.id}`}>
6268 {scopeLabel ?? note.selector.raw}
63 </span>
69+ </button>
6470 <span className="ml-auto flex items-center gap-1.5">
65 {note.risk ? <RiskPill risk={note.risk} /> : null}
66 {note.confidence !== undefined ? <Confidence value={note.confidence} /> : null}
71+ <span className="text-[10px] text-muted-foreground" title="Priority to act, independent of risk">{note.urgency ?? "unspecified"}</span>
72+ {note.confidence !== undefined ? <Confidence value={note.confidence} /> : <span className="text-[10px] text-muted-foreground">confidence unknown</span>}
6773 </span>
6874 </div>
6975  
70 <div className="space-y-2 px-2.5 pt-1.5 pb-2.5">
76+ <div id={`comment-body-${note.id}`} hidden={!expanded} className="space-y-2 px-2.5 pt-1 pb-2.5">
77+ {note.risk ? <RiskPill risk={note.risk} /> : null}
7178 {note.why ? (
7279 <p className="text-[13px] leading-[1.5] text-foreground/95">{inline(note.why)}</p>
7380 ) : null}
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown

src/components/whymark/pills.tsx

+3 3read-only3100%
@@ -62,7 +62,7 @@export function Confidence({ value }: { value: number }) {
6262 />
6363 ))}
6464 </span>
65 {pct}%
65+ {pct}% confidence
6666 </span>
6767 );
6868 }
@@ -71,7 +71,7 @@export function SourceRow({ source }: { source: SourceRef }) {
7171 const meta = SOURCE_META[source.type] ?? SOURCE_META.other;
7272 const Icon = meta.icon;
7373 const isInference = source.type === "inference";
74 const href = source.type === "url" || /^https?:\/\//.test(source.locator) ? source.locator : null;
74+ const href = /^https?:\/\//.test(source.locator) ? source.locator : null;
7575  
7676 return (
7777 <li
@@ -128,7 +128,7 @@export function VerifyRow({ claim }: { claim: VerifyClaim }) {
128128 className="text-[10px] font-medium uppercase tracking-wide"
129129 style={{ color }}
130130 >
131 {STATUS_LABEL[claim.status]}
131+ {claim.method === "none" ? STATUS_LABEL[claim.status] : `claimed ${STATUS_LABEL[claim.status]}`}
132132 </span>
133133 <span className="text-[10px] text-muted-foreground">{meta.label}</span>
134134 </div>
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown

src/components/whymark/review-view.tsx

+52 15read-only14100%
@@ -27,6 +27,12 @@import { CoverageBar, Ring, Stat } from "./meters";
2727 import { Markdown } from "./markdown";
2828 import { NoteCard } from "./note-card";
2929 import { useMediaQuery } from "./use-media-query";
30+import { QualityPanel } from "./quality-panel";
31+import { EvidencePanel } from "./evidence-panel";
32+import { NoteDisclosure } from "./note-disclosure";
33+import { ReviewSettings } from "./review-settings";
34+import { useReviewPreferences } from "./use-review-preferences";
35+import { type ReviewPreferences } from "@/lib/review-preferences";
3036 import { hasPassingVerify } from "@/lib/whymark/stats";
3137  
3238 interface ReviewViewProps {
@@ -40,8 +46,28 @@interface ReviewViewProps {
4046  
4147 export function ReviewView({ review, slug, issues, onBack }: ReviewViewProps) {
4248 const compact = !useMediaQuery("(min-width: 1180px)", true);
43 const [mode, setMode] = useState<ViewMode>("unified");
49+ const [preferences, setPreferences] = useReviewPreferences();
4450 const [activeNoteId, setActiveNoteId] = useState<string | null>(null);
51+ const [disclosures, setDisclosures] = useState<Record<string, boolean>>({});
52+ const updatePreferences = (next: ReviewPreferences) => {
53+ setPreferences(next);
54+ if (next.collapseNotes !== preferences.collapseNotes) setDisclosures({});
55+ };
56+ const activate = useCallback((id: string | null) => {
57+ setActiveNoteId(id);
58+ if (id) setDisclosures(current => ({ ...current, [id]: true }));
59+ }, []);
60+ useEffect(() => {
61+ const reveal = () => {
62+ let hash: string;
63+ try { hash = decodeURIComponent(window.location.hash.slice(1)); } catch { return; }
64+ if (hash.startsWith("note-")) activate(hash.slice(5));
65+ };
66+ const frame = requestAnimationFrame(reveal);
67+ window.addEventListener("hashchange", reveal);
68+ return () => { cancelAnimationFrame(frame); window.removeEventListener("hashchange", reveal); };
69+ }, [activate]);
70+ const [mode, setMode] = useState<ViewMode>("unified");
4571 const [kindFilter, setKindFilter] = useState<Set<NoteKind>>(new Set());
4672 const [onlyUnverified, setOnlyUnverified] = useState(false);
4773 const [onlyInference, setOnlyInference] = useState(false);
@@ -74,10 +100,8 @@export function ReviewView({ review, slug, issues, onBack }: ReviewViewProps) {
74100  
75101 const orderedVisible = useMemo(
76102 () =>
77 review.files
78 .flatMap((file) => file.notes)
79 .filter((note) => visibleNoteIds.has(note.id)),
80 [review.files, visibleNoteIds],
103+ allNotes.filter((note) => visibleNoteIds.has(note.id)),
104+ [allNotes, visibleNoteIds],
81105 );
82106  
83107 const jump = useCallback(
@@ -89,13 +113,13 @@export function ReviewView({ review, slug, issues, onBack }: ReviewViewProps) {
89113 (index + delta + orderedVisible.length * 2) % orderedVisible.length
90114 ];
91115 if (!next) return;
92 setActiveNoteId(next.id);
116+ activate(next.id);
93117 const element = document.getElementById(`note-${next.id}`);
94118 element?.scrollIntoView({ behavior: "smooth", block: "center" });
95119 element?.classList.remove("whymark-flash");
96120 requestAnimationFrame(() => element?.classList.add("whymark-flash"));
97121 },
98 [orderedVisible, activeNoteId],
122+ [orderedVisible, activeNoteId, activate],
99123 );
100124  
101125 const setFileDecisions = useCallback((path: string, next: FileDecisions) => {
@@ -215,7 +239,7 @@export function ReviewView({ review, slug, issues, onBack }: ReviewViewProps) {
215239 const onKey = (event: KeyboardEvent) => {
216240 if (event.metaKey || event.ctrlKey || event.altKey) return;
217241 const target = event.target as HTMLElement | null;
218 if (target && /input|textarea|select/i.test(target.tagName)) return;
242+ if (target && (/input|textarea|select/i.test(target.tagName) || target.isContentEditable || target.closest('[role="dialog"]'))) return;
219243  
220244 switch (event.key) {
221245 case "j":
@@ -255,10 +279,14 @@export function ReviewView({ review, slug, issues, onBack }: ReviewViewProps) {
255279 (issue) => issue.code === "review-stale" || issue.code === "file-missing",
256280 );
257281 const problems = issues.filter(
258 (issue) => issue.level === "error" || issue.code === "note-stub",
282+ (issue) => issue.level === "error" || ["note-stub", "source-unavailable", "evidence-invalid", "quality-invalid"].includes(issue.code),
259283 );
260284  
261285 return (
286+ <NoteDisclosure.Provider value={{
287+ expanded: id => disclosures[id] ?? !preferences.collapseNotes,
288+ toggle: id => setDisclosures(current => ({ ...current, [id]: !(current[id] ?? !preferences.collapseNotes) })),
289+ }}>
262290 <div className="min-h-dvh">
263291 <header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-border/70 bg-[color-mix(in_oklch,var(--background)_88%,transparent)] px-4 backdrop-blur-md">
264292 {onBack ? (
@@ -295,6 +323,6 @@export function ReviewView({ review, slug, issues, onBack }: ReviewViewProps) {
295323 />
296324 <span className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
297325 <Check className="size-3" style={{ color: STATUS_COLOR.pass }} />
298 {Math.round(stats.verifiedCoverage * 100)}% verified
326+ {Math.round(stats.verifiedCoverage * 100)}% claimed verified
299327 </span>
300328 </div>
@@ -335,6 +363,8 @@export function ReviewView({ review, slug, issues, onBack }: ReviewViewProps) {
335363 </button>
336364 </div>
337365  
366+ <ReviewSettings value={preferences} onChange={updatePreferences} />
367+ 
338368 <button
339369 onClick={() => setShowHelp(true)}
340370 className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground"
@@ -404,15 +434,15 @@export function ReviewView({ review, slug, issues, onBack }: ReviewViewProps) {
404434 </details>
405435 ) : null}
406436  
407 {review.docNotes.length ? (
437+ {review.docNotes.some(note => visibleNoteIds.has(note.id)) ? (
408438 <div className="space-y-2">
409 {review.docNotes.map((note) => (
439+ {review.docNotes.filter(note => visibleNoteIds.has(note.id)).map((note) => (
410440 <NoteCard
411441 key={note.id}
412442 note={note}
413443 scopeLabel="whole change"
414444 active={activeNoteId === note.id}
415 onSelect={setActiveNoteId}
445+ onSelect={activate}
416446 />
417447 ))}
418448 </div>
@@ -435,6 +465,6 @@export function ReviewView({ review, slug, issues, onBack }: ReviewViewProps) {
435465 <div className="text-[12.5px] font-medium">
436466 {Math.round(stats.verifiedCoverage * 100)}%
437467 </div>
438 <div className="text-[11.5px] text-muted-foreground">verified</div>
468+ <div className="text-[11.5px] text-muted-foreground">claimed verified</div>
439469 </div>
440470 </div>
@@ -520,6 +550,8 @@export function ReviewView({ review, slug, issues, onBack }: ReviewViewProps) {
520550 </div>
521551 </div>
522552  
553+ <div className="mt-4"><EvidencePanel evidence={review.evidence} /><QualityPanel report={review.quality} state={review.qualityState} files={review.files} /></div>
554+ 
523555 {/* filters */}
524556 <div className="mt-5 flex flex-wrap items-center gap-1.5 border-y border-border/60 py-2.5">
525557 <span className="mr-1 text-[11px] uppercase tracking-wide text-muted-foreground">
@@ -589,6 +621,8 @@export function ReviewView({ review, slug, issues, onBack }: ReviewViewProps) {
589621 </button>
590622 ) : null}
591623  
624+ <button className="rounded border px-2 py-0.5 text-[11px]" onClick={() => setDisclosures(Object.fromEntries(allNotes.map(note => [note.id, false])))}>Collapse all</button>
625+ <button className="rounded border px-2 py-0.5 text-[11px]" onClick={() => setDisclosures(Object.fromEntries(allNotes.map(note => [note.id, true])))}>Expand all</button>
592626 <span className="ml-auto text-[11px] text-muted-foreground">
593627 {visibleNoteIds.size} of {allNotes.length} shown ·{" "}
594628 <kbd className="rounded border border-border/70 px-1 font-mono text-[10px]">j</kbd>{" "}
@@ -606,9 +640,11 @@export function ReviewView({ review, slug, issues, onBack }: ReviewViewProps) {
606640 index={index}
607641 mode={mode}
608642 compact={compact}
643+ syncScroll={preferences.syncScroll}
644+ compactRail={preferences.compactRail}
609645 visibleNoteIds={visibleNoteIds}
610646 activeNoteId={activeNoteId}
611 onActivate={setActiveNoteId}
647+ onActivate={activate}
612648 editable={Boolean(slug)}
613649 decisions={decisions[file.path]}
614650 onDecisions={setFileDecisions}
@@ -643,5 +679,6 @@export function ReviewView({ review, slug, issues, onBack }: ReviewViewProps) {
643679  
644680 {showHelp ? <HelpOverlay onClose={() => setShowHelp(false)} /> : null}
645681 </div>
682+ </NoteDisclosure.Provider>
646683 );
647684 }
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown

src/lib/view-model.ts

+11 1read-only4100%
@@ -1,3 +1,6 @@
1+import { readQuality } from "./quality/import";
2+import type { QualityReport } from "./quality/types";
3+import { evidenceViews, type EvidenceView } from "./whymark/evidence";
14 import { buildRows } from "@/lib/whymark/align";
25 import { inlineParts, linePairs, type InlinePart } from "@/lib/whymark/inline";
36 import { languageFor } from "@/lib/whymark/lang";
@@ -42,6 +45,9 @@export interface FileVM {
4245 }
4346  
4447 export interface ReviewVM {
48+ evidence: EvidenceView[];
49+ quality: QualityReport | null;
50+ qualityState: "current" | "stale" | "unchecked";
4551 meta: Meta;
4652 files: FileVM[];
4753 docNotes: Note[];
@@ -51,7 +57,7 @@export interface ReviewVM {
5157  
5258 export async function buildReviewVM(
5359 doc: WhymarkDocument,
54 options: { isWritable?: (file: FileSection) => boolean } = {},
60+ options: { isWritable?: (file: FileSection) => boolean; evidence?: EvidenceView[]; source?: string | null; reviewHash?: string } = {},
5561 ): Promise<ReviewVM> {
5662 const stats = computeStats(doc);
5763 const files: FileVM[] = [];
@@ -108,7 +114,11 @@export async function buildReviewVM(
108114 });
109115 }
110116  
117+ const quality = readQuality(doc.meta.extra.quality);
111118 return {
119+ quality,
120+ qualityState: !quality || !options.source ? "unchecked" : quality.source === options.source && quality.sourceAfter === options.source && quality.reviewHash === options.reviewHash ? "current" : "stale",
121+ evidence: options.evidence ?? evidenceViews(doc),
112122 meta: doc.meta,
113123 files,
114124 docNotes: doc.notes,
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown

src/lib/whymark/parse.ts

+19 1read-only6100%
@@ -1,6 +1,8 @@
11 import YAML from "yaml";
22 import {
33 WHYMARK_VERSION,
4+ URGENCIES,
5+ type Urgency,
46 NOTE_KINDS,
57 VERIFY_METHODS,
68 type WhymarkDocument,
@@ -64,6 +66,7 @@const KNOWN_FIELDS = new Set([
6466 ...REPEATABLE,
6567 "kind",
6668 "risk",
69+ "urgency",
6770 "confidence",
6871 "id",
6972 ]);
@@ -760,6 +763,11 @@function parseNoteHeader(
760763 });
761764 }
762765  
766+ const urgency = URGENCIES.includes(attrs.urgency as Urgency) ? attrs.urgency as Urgency : undefined;
767+ if (attrs.urgency && !urgency) diagnostics.push({
768+ level: "warning", code: "urgency-unknown", message: `Unknown urgency ${attrs.urgency}; preserved as a field.`, line: lineNo,
769+ });
770+ 
763771 let confidence: number | undefined;
764772 if (attrs.confidence) {
765773 const value = Number(attrs.confidence.replace("%", ""));
@@ -796,6 +804,7 @@function parseNoteHeader(
796804 selector,
797805 kind,
798806 risk,
807+ urgency,
799808 confidence,
800809 sources: [],
801810 verify: [],
@@ -804,7 +813,7 @@function parseNoteHeader(
804813 questions: [],
805814 refs: [],
806815 body: "",
807 extra: {},
816+ extra: attrs.urgency && !urgency ? { urgency: [attrs.urgency] } : {},
808817 sourceLine: lineNo,
809818 file: filePath,
810819 };
@@ -899,6 +908,15 @@function setField(
899908 }
900909 }
901910  
911+ if (key === "urgency") {
912+ if (URGENCIES.includes(value as Urgency)) {
913+ note.urgency = value as Urgency;
914+ ctx.lastField = null;
915+ return;
916+ }
917+ diagnostics.push({ level: "warning", code: "urgency-unknown", message: `Unknown urgency ${value}; preserved as a field.`, line: lineNo, noteId: note.id });
918+ }
919+ 
902920 // Attributes are allowed as fields too, which is how models often write them.
903921 if (key === "kind" || key === "risk" || key === "confidence" || key === "id") {
904922 const num = Number(value.replace("%", ""));
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown

src/lib/whymark/serialize.ts

+1 0read-only1100%
@@ -135,6 +135,7 @@export function serializeNote(note: Note, wrap = 78): string {
135135 const field = (name: string, value: string) =>
136136 lines.push(...wrapField(name, value, wrap));
137137  
138+ if (note.urgency) field("urgency", note.urgency);
138139 if (note.why) field("why", note.why);
139140 if (note.what) field("what", note.what);
140141 for (const source of note.sources) field("source", source.raw);
intentnormalconfidence unknown

src/lib/whymark/stats.ts

+1 1read-only1100%
@@ -70,7 +70,7 @@export function coveredLines(file: FileSection): {
7070 }
7171  
7272 export function hasPassingVerify(note: Note): boolean {
73 return note.verify.some((v) => v.status === "pass" && v.method !== "none");
73+ return !note.verify.some(v => v.status === "fail") && note.verify.some((v) => v.status === "pass" && v.method !== "none");
7474 }
7575  
7676 export function addedLineNumbers(file: FileSection): number[] {
intentnormalconfidence unknown

src/lib/whymark/types.ts

+4 0read-only2100%
@@ -50,6 +50,9 @@export const NOTE_KINDS: NoteKind[] = [
5050 "note",
5151 ];
5252  
53+export const URGENCIES = ["info", "normal", "urgent", "blocking"] as const;
54+export type Urgency = (typeof URGENCIES)[number];
55+ 
5356 export type Risk = "low" | "medium" | "high";
5457  
5558 export type VerifyStatus = "pass" | "fail" | "unknown" | "skipped";
@@ -121,6 +124,7 @@export interface Note {
121124 selector: Selector;
122125 kind: NoteKind;
123126 risk?: Risk;
127+ urgency?: Urgency;
124128 confidence?: number;
125129 why?: string;
126130 what?: string;
intentnormalconfidence unknown
intentnormalconfidence unknown

src/lib/whymark/validate-tree.ts

+43 3read-only2100%
@@ -1,12 +1,13 @@
1import { existsSync } from "node:fs";
2import { join } from "node:path";
1+import { existsSync, readFileSync, realpathSync } from "node:fs";
2+import { execFileSync } from "node:child_process";
3+import { join, relative, resolve, sep } from "node:path";
34 import { hashObject } from "./git";
45 import {
56 validateDocument,
67 type ValidateOptions,
78 type ValidationResult,
89 } from "./validate";
9import type { Diagnostic, WhymarkDocument } from "./types";
10+import { allNotes, type Diagnostic, type WhymarkDocument } from "./types";
1011  
1112 /** Working-tree hash comparison — Node only, not used by the hosted viewer. */
1213 export function collectStaleness(doc: WhymarkDocument, cwd: string): Diagnostic[] {
@@ -52,5 +53,44 @@export function validateDocumentInRepo(
5253 const extra = options.skipStaleness
5354 ? []
5455 : collectStaleness(doc, options.cwd ?? process.cwd());
56+ if (!options.skipStaleness) extra.push(...collectSourceDiagnostics(doc, options.cwd ?? process.cwd()));
5557 return validateDocument(doc, { ...options, extraDiagnostics: extra });
5658 }
59+ 
60+export function collectSourceDiagnostics(doc: WhymarkDocument, cwd: string): Diagnostic[] {
61+ const diagnostics: Diagnostic[] = [];
62+ for (const note of allNotes(doc)) for (const source of note.sources) {
63+ let problem: string | null = null;
64+ try {
65+ if (source.type === "file") {
66+ const match = source.locator.match(/^(.*?)(?::(\d+)(?:-(\d+))?)?$/);
67+ if (!match || /[*?]/.test(match[1])) continue;
68+ const path = match[1];
69+ if (path.startsWith("/") || path.split(/[\\/]/).includes("..")) throw new Error("reference escapes repository");
70+ let content: string;
71+ const revision = doc.meta.scope === "commit" ? doc.meta.head?.match(/[a-f0-9]{7,40}$/)?.[0] : undefined;
72+ if (doc.meta.scope === "commit" && !revision) throw new Error("recorded commit unavailable");
73+ if (revision) content = execFileSync("git", ["show", `${revision}:${path}`], { cwd, encoding: "utf8", maxBuffer: 2 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
74+ else {
75+ const root = realpathSync(cwd);
76+ const absolute = realpathSync(resolve(root, path));
77+ const rel = relative(root, absolute);
78+ if (rel === ".." || rel.startsWith(`..${sep}`)) throw new Error("reference escapes repository");
79+ content = readFileSync(absolute, "utf8");
80+ }
81+ const count = content.replace(/\n$/, "").split("\n").length;
82+ if (match[2] && (Number(match[2]) < 1 || Number(match[3] ?? match[2]) > count || Number(match[3] ?? match[2]) < Number(match[2]))) problem = "line range is outside the file";
83+ } else if (source.type === "commit") {
84+ if (!/^[a-f0-9]{7,40}$/.test(source.locator)) throw new Error("expected a commit hash");
85+ execFileSync("git", ["cat-file", "-e", `${source.locator}^{commit}`], { cwd, stdio: "ignore" });
86+ } else if (source.type === "dep") {
87+ const match = source.locator.match(/^(@?[^@]+)@(.+)$/);
88+ if (!match || !/^(@[a-z0-9_.-]+\/)?[a-z0-9_.-]+$/i.test(match[1])) throw new Error("expected dependency@version");
89+ const pkg = JSON.parse(readFileSync(join(cwd, "node_modules", match[1], "package.json"), "utf8"));
90+ if (pkg.version !== match[2]) problem = "installed dependency version differs from the citation";
91+ }
92+ } catch { problem = "reference unavailable at the reviewed location"; }
93+ if (problem) diagnostics.push({ level: "warning", code: "source-unavailable", message: `${source.locator}: ${problem}. Locator checks do not establish that a source supports the claim.`, noteId: note.id, file: note.file ?? undefined });
94+ }
95+ return diagnostics;
96+}
intentnormalconfidence unknown
intentnormalconfidence unknown

src/lib/whymark/validate.ts

+9 0read-only2100%
@@ -1,2 +1,4 @@
1+import { readEvidence } from "./evidence";
2+import { readQuality } from "../quality/import";
13 import { computeStats, hasPassingVerify, type DocStats } from "./stats";
24 import { allNotes, isStub, type WhymarkDocument, type Diagnostic } from "./types";
@@ -44,6 +46,13 @@export function validateDocument(
4446 });
4547 }
4648  
49+ if (doc.meta.extra.evidence !== undefined && (!Array.isArray(doc.meta.extra.evidence) || readEvidence(doc.meta.extra.evidence).length !== doc.meta.extra.evidence.length)) {
50+ diagnostics.push({ level: "warning", code: "evidence-invalid", message: "Malformed execution evidence was preserved but cannot establish verification." });
51+ }
52+ if (doc.meta.extra.quality !== undefined && !readQuality(doc.meta.extra.quality)) {
53+ diagnostics.push({ level: "warning", code: "quality-invalid", message: "Malformed quality report was preserved but cannot establish a clean scan." });
54+ }
55+ 
4756 if (!doc.meta.summary?.trim()) {
4857 diagnostics.push({
4958 level: "warning",
intentnormalconfidence unknown
intentnormalconfidence unknown

src/lib/whymark/verify.ts

+27 2read-only8100%
@@ -1,2 +1,4 @@
1+import { readEvidence, type ExecutionEvidence } from "./evidence";
2+import { gitHead, reviewFingerprint, sourceFingerprint, writeOutput } from "./evidence-node";
13 import { spawnSync } from "node:child_process";
24 import { allNotes, type Check, type WhymarkDocument, type Note, type VerifyClaim } from "./types";
@@ -10,6 +12,7 @@export interface RunResult {
1012 durationMs: number;
1113 output: string;
1214 timedOut: boolean;
15+ unavailable: boolean;
1316 }
1417  
1518 export interface ClaimResult {
@@ -25,6 +28,8 @@export interface ClaimResult {
2528  
2629 export interface VerifyOptions {
2730 cwd?: string;
31+ recordEvidence?: boolean;
32+ toolVersion?: string;
2833 timeoutMs?: number;
2934 /** Only run commands matching this pattern. */
3035 filter?: RegExp;
@@ -49,6 +54,7 @@export function runCommand(cmd: string, options: VerifyOptions = {}): RunResult
4954 status: result.status === 0 ? "pass" : "fail",
5055 durationMs: Date.now() - started,
5156 output,
57+ unavailable: Boolean(result.error || result.status === null || result.status === 127),
5258 timedOut: Boolean(result.error && /ETIMEDOUT|timed out/i.test(String(result.error))),
5359 };
5460 }
@@ -59,6 +65,9 @@export function verifyDocument(
5965 options: VerifyOptions = {},
6066 ): ClaimResult[] {
6167 const results: ClaimResult[] = [];
68+ const cwd = options.cwd ?? process.cwd();
69+ const before = options.recordEvidence ? sourceFingerprint(cwd) : null;
70+ const recordedHead = options.recordEvidence ? gitHead(cwd) : null;
6271 const cache = new Map<string, RunResult>();
6372  
6473 const run = (cmd: string): RunResult => {
@@ -78,7 +87,7 @@export function verifyDocument(
7887 file: null,
7988 claimed: check.status,
8089 cmd: check.cmd,
81 outcome: outcomeFor(check.status, result.status),
90+ outcome: result.unavailable ? "unrunnable" : outcomeFor(check.status, result.status),
8291 run: result,
8392 };
8493 applyToCheck(check, result);
@@ -112,7 +121,7 @@export function verifyDocument(
112121 file: note.file,
113122 claimed: claim.status,
114123 cmd,
115 outcome: outcomeFor(claim.status, result.status),
124+ outcome: result.unavailable ? "unrunnable" : outcomeFor(claim.status, result.status),
116125 run: result,
117126 };
118127 applyToClaim(claim, result);
@@ -121,5 +130,21 @@export function verifyDocument(
121130 }
122131 }
123132  
133+ if (options.recordEvidence) {
134+ const after = sourceFingerprint(cwd);
135+ const previous = readEvidence(doc.meta.extra.evidence);
136+ const additions: ExecutionEvidence[] = results.filter(result => result.run).map(result => {
137+ const run = result.run!;
138+ return {
139+ version: 1, command: result.cmd, noteId: result.noteId, file: result.file,
140+ claimed: result.claimed, status: run.unavailable ? "unavailable" : run.status,
141+ ran: new Date().toISOString(), durationMs: run.durationMs, exitCode: run.exitCode,
142+ cwd: ".", tool: `whymark@${options.toolVersion ?? "development"}`, runtime: process.version,
143+ source: before, sourceAfter: after, head: recordedHead, base: doc.meta.base ?? null,
144+ reviewHash: reviewFingerprint(doc), ...writeOutput(cwd, run.output), summary: summarise(run),
145+ };
146+ });
147+ doc.meta.extra.evidence = [...previous.filter(old => !additions.some(next => next.command === old.command && next.noteId === old.noteId && next.file === old.file)), ...additions];
148+ }
124149 return results;
125150 }
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown
intentnormalconfidence unknown

tests/e2e/viewer.mjs

+2 0read-only1100%
@@ -17,6 +17,8 @@const check = (name, ok, detail = "") =>
1717 const browser = await chromium.launch();
1818 const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
1919  
20+await page.addInitScript(() => localStorage.setItem("whymark.preferences.v1", JSON.stringify({ version: 1, syncScroll: true, collapseNotes: false, compactRail: false })));
21+ 
2022 const consoleErrors = [];
2123 page.on("console", (msg) => {
2224 if (msg.type() === "error") consoleErrors.push(msg.text());
intentnormalconfidence unknown

specs/001-review-improvements/spec.md

+290 0read-only1100%
@@ -0,0 +1,290 @@
1+# Review improvements: requirements and delivery roadmap
2+ 
3+Status: initial delivery implemented; see `verification.md`. Date: 2026-09-17.
4+Baseline inspected: `28e5280`; working trees were clean before this documentation work.
5+Classification: Large — viewer interaction, a format extension, npm distribution,
6+and evidence ingestion need separate, independently verifiable deliveries.
7+ 
8+## Goal
9+ 
10+Make AI-authored changes easier to inspect without losing the distinction between
11+what an author claims and what tools actually checked. Support several precise
12+annotations on the same code, compact disclosure, stable diff colors, synchronized
13+split scrolling, an npm-delivered agent skill, and deterministic quality checks.
14+ 
15+## Scope and assumptions
16+ 
17+- “Comments” initially means existing line-addressed `@note` annotations. Multiple
18+ annotations on a range are required; discussion replies and browser authoring are
19+ a separate proposal below, because they need authorship and persistence semantics.
20+- “Estimated accuracy” means explicitly labeled author confidence. Do not invent a
21+ measured accuracy percentage from coverage, passing tests, citations, or lint.
22+- Urgency is distinct from risk: priority to act versus blast radius if wrong.
23+- Retain v1 reading and existing line/part/hunk apply behavior.
24+- The initial task registered Vault context; the subsequent implementation request
25+ authorized the deliveries below. Publishing, deployment, commits and global skill
26+ installation remain separate actions.
27+- No new hosted backend, accounts, AI service, or automatic code rewrite in this scope.
28+ 
29+## Observed baseline and files to read
30+ 
31+| Files | Current behavior and implication |
32+| --- | --- |
33+| `spec/whymark-v1.md`, `src/lib/whymark/types.ts`, `parse.ts`, `serialize.ts` | Repeated `@note` selectors already work; confidence and risk exist, urgency does not. |
34+| `src/lib/whymark/align.ts`, `src/lib/view-model.ts` | Rows carry `noteIds[]`; `layoutRail` inserts code padding to align cards. |
35+| `src/components/whymark/file-panel.tsx` | Row hover/click uses `noteIds[0]`; `rowTint` replaces diff tone with category tone. Split panes scroll independently. |
36+| `src/components/whymark/note-card.tsx`, `pills.tsx`, `meta.tsx` | Expanded cards, type/risk/confidence badges; no per-card disclosure. |
37+| `src/components/whymark/review-view.tsx`, `src/components/ui/dialog.tsx` | Existing navigation, filters and review state; reuse installed dialog primitives for settings. |
38+| `src/components/whymark/code-line.tsx`, `src/lib/whymark/inline.ts`, `edit.ts` | Shared inline parts are the contract between highlighting and file writes. |
39+| `src/lib/whymark/stats.ts`, `verify.ts`, `validate-tree.ts` | Passing claims count toward verified coverage; CLI reruns shell commands; blob hashes detect stale files. |
40+| `src/cli/whymark.ts`, `src/cli/help.ts`, `package.json`, `bin/whymark.mjs` | Existing CLI and npm packaging; package allowlist excludes `.agents/skills/whymark`. |
41+| `.agents/skills/whymark/SKILL.md`, `prompts/whymark-author.md` | Existing guidance already asks for tight ranges, real sources, and honest verification. |
42+| `tests/parse.test.ts`, `tests/review.test.ts`, `tests/cli.test.ts`, `tests/e2e/viewer.mjs` | Extend existing unit/CLI/browser patterns; preserve edit/apply regressions. |
43+ 
44+The hover cause is supported by source inspection. This planning task has not
45+reproduced the reported interaction in a browser; capture that baseline before fixing it.
46+ 
47+## Delivery sequence
48+ 
49+| Order | Package | Depends on | Completion boundary |
50+| --- | --- | --- | --- |
51+| 1 | WM-01: semantic diff colors | none | Category hover cannot turn an addition into a red deletion-like row. |
52+| 2 | WM-02: multiple compact comments | WM-01 | Every overlapping note reachable; disclosure removes padding pressure. |
53+| 3 | WM-03: settings and split scrolling | WM-02 | Bidirectional horizontal sync, accessible settings, independent mode. |
54+| 4 | WM-04: npm skill distribution | WM-02 contract | Packed artifact installs the skill without Git access. |
55+| 5 | WM-05: trustworthy evidence presentation | none; integrate after WM-02 | Claims, fresh execution, stale evidence and inference distinguished. |
56+| 6 | WM-06: deterministic quality findings | WM-05 | Versioned findings tied to the reviewed source, with baseline comparison. |
57+ 
58+Each package should be a small reviewable change with its own passing checks.
59+WM-04 can proceed independently once the annotation contract and guidance settle.
60+Do not wait for the scanner to ship the UI fixes.
61+ 
62+## WM-01 — preserve diff meaning during hover
63+ 
64+**Change:** modify `file-panel.tsx`, `code-line.tsx` only if necessary, and
65+`src/app/globals.css`; extend `tests/e2e/viewer.mjs`.
66+ 
67+- Addition/deletion backgrounds remain green/red during hover, selection and focus.
68+- Indicate the annotation using a gutter marker or outline; keep category color on
69+ its badge/card. Do not replace diff backgrounds or syntax-token colors.
70+- Preserve distinct restored, rejected and partially reverted states.
71+- Test a security note on an addition, a deletion and unchanged context in unified
72+ and split views, including overlapping annotations and keyboard focus.
73+- Capture before/after screenshots and computed style assertions. The existing
74+ part-revert/apply tests must still show exactly the bytes that will be written.
75+ 
76+## WM-02 — multiple comments, disclosure and tighter layout
77+ 
78+**Change:** `note-card.tsx`, `file-panel.tsx`, `review-view.tsx`, `pills.tsx`,
79+`align.ts`, relevant types/parser/serializer/validator, format documentation,
80+author prompt, skill, `tests/parse.test.ts`, `tests/review.test.ts`, and browser tests.
81+Add focused `tests/align.test.ts` if existing tests cannot cover layout cleanly.
82+ 
83+- Keep all matching note IDs, with stable document order. A line shows a count;
84+ selecting it exposes all applicable notes, with keyboard-selectable entries.
85+ Filtering must not leave a hidden first note intercepting selection.
86+- Each card has a real disclosure button with `aria-expanded` and `aria-controls`.
87+ Collapsed cards retain type, urgency and author confidence; missing values show
88+ “unspecified”/“unknown”. Keep the line selector visible for orientation.
89+- Proposed new optional header: `urgency=info|normal|urgent|blocking`. Never infer
90+ blocking from `kind=security`, and never reinterpret existing `risk` as urgency.
91+- Add urgency across spec, types, parser, serializer, validation, examples, skill and
92+ author prompt together. Preserve legacy input and unknown values without data loss;
93+ fixture-test old readers' behavior before choosing an additive v1 release. If they
94+ cannot preserve it, decide versioning before shipping the extension.
95+- Keep disclosure state separate from code acceptance/rejection and note selection.
96+ Clicking a link or disclosure must not reject code. Keyboard navigation to a
97+ collapsed note reveals it. Provide collapse-all/expand-all with per-note override.
98+- Proposed default: compact collapsed notes; selecting a note expands that note.
99+- Group notes sharing an anchor. Use measured collapsed/expanded heights and a
100+ compact rail that does not insert blank code rows to accommodate long comments.
101+ Connect displaced cards to their exact range; allow explicit selection to bring
102+ code and card into view. Keep both code columns vertically aligned.
103+- At 1600px, a fixture with three long notes on adjacent lines must retain the
104+ ordinary 21px code-line rhythm; expanding a note must not create card overlap.
105+ At 390px, notes remain reachable with no page-wide horizontal overflow.
106+- Author guidance: one claim/reason per note, narrowest meaningful range, first
107+ sentence states the decision and consequence, evidence explains why the source
108+ supports the claim. Do not fill every line with repetitive commentary to game coverage.
109+- Tests: three notes on one line, partially overlapping old/new ranges, file/doc notes,
110+ unknown urgency, missing confidence, round-trip preservation, filtering, deep links,
111+ resize, keyboard navigation and disclosure after changing view mode.
112+ 
113+## WM-03 — settings modal and synchronized split scrolling
114+ 
115+**Change:** `review-view.tsx`, `file-panel.tsx`, new
116+`src/components/whymark/review-settings.tsx`, new `src/lib/review-preferences.ts`,
117+and browser tests. Reuse `src/components/ui/dialog.tsx`.
118+ 
119+- Settings button opens a labeled modal with focus containment, Escape dismissal,
120+ keyboard switches and focus return to the opener.
121+- First settings: synchronize horizontal scrolling (default on), default comment
122+ disclosure, and compact versus expanded annotation presentation. Add reset defaults.
123+- Synchronize only the two sides of the same file in either direction. Equal pixel
124+ offsets are preferable to percentages because code columns should line up. Clamp
125+ the destination to its available range without moving the originating pane back.
126+- Avoid feedback loops from programmatic scroll events, handle resize/short panes,
127+ and clean up handlers. No coupling between files or forced vertical scrolling.
128+- Disabling sync lets each pane move independently. Re-enabling aligns to the last
129+ user-scrolled pane. Switching unified/split preserves the preference.
130+- Persist only harmless viewer preferences in existing browser-local storage style,
131+ with versioning and an in-memory fallback for blocked/corrupt storage. Review text,
132+ evidence and code decisions must not enter the preferences object.
133+- Test both directions, unequal widths, no overflow, rapid alternating scrolls,
134+ toggling off/on, multiple files, reload, blocked storage and modal accessibility.
135+ 
136+## WM-04 — install the agent skill using npm
137+ 
138+**Change:** `package.json`, `src/cli/whymark.ts`, `src/cli/help.ts`,
139+`.agents/skills/whymark/SKILL.md`, `prompts/whymark-author.md`, `README.md`,
140+`tests/cli.test.ts`; add `tests/package.test.ts` and a small installer module if useful.
141+ 
142+- Keep the existing package; explicitly include the canonical skill and required
143+ references in its npm file allowlist. Do not depend on cloning the private repo.
144+- Proposed command (not implemented): `npx whymark@<version> skill install`.
145+ Project-local installation is default; explicit agent and global options select
146+ destinations. Start with Codex and Claude Code, then extend documented targets.
147+- Resolve bundled files relative to the installed package, not the current checkout.
148+ All relative skill links must resolve in the installed skill. Include a minimal
149+ working review example and the relevant format reference with the installation.
150+- Preview destinations; identical installs are idempotent. Refuse to overwrite
151+ customized files without explicit replacement. Detect unsafe symlink destinations.
152+ Do not modify unrelated `AGENTS.md`/`CLAUDE.md` or run postinstall mutations.
153+- Keep `init` as its existing alias for `new` (review skeleton generation); document
154+ `skill install` separately so installation cannot replace that command's behavior.
155+- Packaging gate: build CLI, `npm pack`, inspect tarball, install into a temporary
156+ consumer with no repository credentials, then execute the packaged installer.
157+ Test project/global targets using an isolated test home, repeats and conflicts.
158+- Public npm content is public even if the repository is private; review the tarball
159+ allowlist before release. Publishing remains a separate authorized action.
160+- `npx skills` does support private repositories using configured authentication,
161+ but that still requires repository access. npm delivery avoids that requirement.
162+ 
163+## WM-05 — evidence a reviewer can inspect
164+ 
165+**Change:** `src/lib/whymark/stats.ts`, `verify.ts`, `validate-tree.ts`, `types.ts`,
166+`src/components/whymark/review-view.tsx`, `pills.tsx`, and new evidence model/tests.
167+Any persisted evidence schema also updates parser, serializer, spec and skill.
168+ 
169+- Rename current “verified” presentation to “claimed verified” unless backed by a
170+ recorded execution bound to this source state. Preserve old reports as legacy claims.
171+- Show distinct states: author claim, execution record, stale record, contradicted,
172+ unavailable, skipped. A pass plus a failure cannot render as unqualified success.
173+- Record command, cwd relative to repo, tool version, execution time, exit code,
174+ output artifact/hash, and source fingerprint including relevant uncommitted files.
175+ Record the reviewed base/head and test/config/lockfile inputs as well as diff hashes.
176+- Hash mismatch invalidates freshness; hashes establish identity, not who ran a check.
177+ Imported results remain externally supplied evidence unless independently rerun or
178+ authenticated. Do not call a successful check proof of correctness.
179+- Show source locator plus the supported claim. Validate local file/line/commit/dep
180+ references against the recorded revision. A reachable URL is not proof it supports
181+ the explanation; semantic relevance remains a reviewer judgment.
182+- Sources stay clickable but are not automatically fetched by the server. Optional
183+ link checking must be separate, bounded, and protect private/internal network targets.
184+- Viewing/importing a review never executes its embedded commands. The existing CLI
185+ verifier runs shell text; show exact commands and require explicit execution intent
186+ before adding any UI runner. Use trusted configured checks for automation.
187+- Tests: fabricated pass with no run, stale tests with unchanged production file,
188+ pass/fail conflict, missing output, timeout, skipped tool, inference-only source,
189+ missing local reference, and hostile imported command that is never auto-executed.
190+ 
191+## WM-06 — automatic checks without AI
192+ 
193+**Change:** new `src/lib/quality/types.ts`, `src/lib/quality/import.ts`,
194+`src/lib/quality/baseline.ts`, `src/lib/quality/run.ts`, quality tests,
195+CLI/help, new `src/components/whymark/quality-panel.tsx`, and optional
196+`whymark.config.json` for explicitly configured local checks.
197+ 
198+Start with existing scripts and an ESLint JSON adapter, then add adapters as separate
199+deliveries. Do not add all tools as mandatory runtime dependencies.
200+ 
201+| Signal | Tool / approach | Limitation |
202+| --- | --- | --- |
203+| Type and rule violations | Existing TypeScript and ESLint; configurable complexity/depth/size warnings | Cannot establish domain correctness or subjective readability. |
204+| Unused exports/files/dependencies | Optional Knip adapter | Requires project-aware configuration and allowances for dynamic usage. |
205+| Security patterns | Optional Semgrep CE with pinned local rules | Rule-dependent; findings require triage, not automatic rejection. |
206+| Dependency advisories | Optional npm audit import | Registry-backed/networked; not an offline scan or exploitability proof. |
207+| Test evidence | Existing test runner and coverage report import | Line coverage is not assertion quality or behavioral completeness. |
208+ 
209+- Normalize tool/version, rule ID, severity, message, file/range, help URL,
210+ fingerprint, source revision, status and artifact reference. Validate import size,
211+ paths and malformed reports; never treat tool output as instructions.
212+- Compare against the base revision to separate new findings from existing debt.
213+ Run tools with full project/dependency context and filter presentation to changed
214+ lines plus related findings; do not assume linting changed files proves the project.
215+- Stable identities account for shifted lines and renames. Expose unanchored findings
216+ at file/project level rather than dropping them. A crashed/missing tool is unavailable,
217+ never a clean scan. Baseline failure means comparison unavailable, not no new issues.
218+- Initially advisory. Later opt-in CI policy can block new errors/high-severity findings,
219+ with explicit, reviewable suppressions carrying reason and expiry.
220+- Automatic mode runs only trusted repository-configured tools after explicit setup,
221+ debounces changes, cancels superseded jobs and prevents stale results replacing newer
222+ ones. Imported `.whymark` commands cannot define that configuration.
223+- Tests: pre-existing/new/resolved finding, rename, line shift, deleted file, missing
224+ baseline, scanner failure, excessive output, suppression expiry and out-of-order jobs.
225+ 
226+## Further features worth considering
227+ 
228+1. Reviewer checklist: inspect high-risk changes, unresolved assumptions, missing tests
229+ and stale evidence before marking a review complete; no universal trust score.
230+2. Reviewed/unreviewed state tied to file hashes, so changed code reopens review work.
231+3. Regression and mutation-test evidence for critical paths, with cost controls;
232+ optional later, because mutation testing can be slow and environment-sensitive.
233+4. Change impact from imports/callers and linked tests, explicitly labeled as a static
234+ approximation. Useful for spotting a correct local change with missed consumers.
235+5. Human replies and resolution history, if needed: stable thread IDs, author/time,
236+ export/persistence rules, and reopen-on-code-change behavior before adding the UI.
237+6. Structured JSON/SARIF import/export once the first native adapter is stable; avoid
238+ making every finding an AI-authored annotation or inflating explanation coverage.
239+ 
240+## Verification and rollout
241+ 
242+- Every package: `npm test`, `npm run typecheck`, `npm run lint`.
243+- Viewer packages: run `npm run dev`, then `npm run test:ui`; inspect screenshots,
244+ browser errors, keyboard behavior, narrow layout, and unchanged apply safeguards.
245+- Viewer build changes: `npm run build`. CLI/package changes: `npm run build:cli`
246+ plus installation from the actual packed tarball, not only source tests.
247+- Format changes: old/new fixtures, parse/serialize round trips, unknown-field recovery
248+ and installed-skill examples all agree. Never silently discard newer metadata.
249+- Add deterministic fail-before/pass-after coverage for the color/selection defects.
250+ New capabilities get acceptance coverage rather than invented baseline failures.
251+- Use the Vault implement-verify harness, record unavailable checks, and perform final
252+ source and behavior passes on unchanged code. Static checks alone do not finish UI work.
253+- Ship incrementally. Preserve old review files and fail closed on stale apply targets.
254+ Preferences have safe defaults; scanner adapters remain opt-in until calibrated.
255+ 
256+## Spec Kit handoff
257+ 
258+Use this document as the requirements input to `/speckit.specify`, then
259+`/speckit.clarify`, `/speckit.plan`, `/speckit.tasks`, and `/speckit.analyze` for the
260+selected delivery package. Keep one feature workspace rather than creating a second
261+parallel plan. Read `~/.vault/skills/implement-verify/SKILL.md` before implementation.
262+The implementation consumed this approved roadmap directly with the Vault
263+implement-verify harness. No Spec Kit scaffolding or additional planning suite was
264+introduced. See `verification.md` for delivered scope and deviations.
265+ 
266+Recommended first implementation: WM-01, followed by WM-02. Have a fresh-context
267+reviewer check the resulting diff against the package's acceptance criteria; resolve
268+correctness gaps rather than expanding into stylistic cleanup.
269+ 
270+## Open decisions (proposed defaults allow planning to proceed)
271+ 
272+- Accept `info|normal|urgent|blocking` urgency, or choose a team priority vocabulary.
273+- Confirm that “multiple comments” means independent annotations initially; human
274+ reply threads and browser editing remain later work unless explicitly requested.
275+- Accept compact collapsed notes as the default, with selection expanding one note.
276+- First installer targets: Codex and Claude Code; npm package stays public as currently
277+ configured. Confirm separately before release if package access should change.
278+- Which repositories/languages should the first quality adapters support beyond this
279+ TypeScript project? Start with TypeScript/ESLint; add language-specific adapters later.
280+ 
281+## External references checked for this plan
282+ 
283+- [npm package file allowlist and publishing configuration](https://docs.npmjs.com/cli/v11/configuring-npm/package-json/).
284+- [Skills CLI private-repository authentication and local sources](https://github.com/vercel-labs/skills#private-repositories).
285+- [ESLint complexity rule](https://eslint.org/docs/latest/rules/complexity).
286+- [Knip setup and project configuration](https://knip.dev/overview/getting-started).
287+- [Semgrep Community Edition](https://semgrep.dev/products/community-edition/).
288+ 
289+These sources inform proposed integrations; no third-party tool installation or scan
290+was performed, and no measured code-accuracy claim is made.
intentnormalconfidence unknown

specs/001-review-improvements/verification.md

+67 0read-only1100%
@@ -0,0 +1,67 @@
1+# Review improvements — implementation evidence
2+ 
3+Date: 2026-09-17. Baseline: `28e5280` plus the previous task's uncommitted
4+AGENTS.md/CLAUDE.md and roadmap. No commits or publication.
5+ 
6+## Disposition
7+ 
8+| Package | Delivered boundary | Evidence |
9+| --- | --- | --- |
10+| WM-01 | Diff backgrounds survive category hover/focus; annotation selection uses outlines/gutters. | New browser regression failed before the fix: green `lab(... / 0.11)` became security red `oklch(... / 0.13)`. It passes after the fix. |
11+| WM-02 | All overlapping notes selectable; disclosure shows type, urgency and author confidence; compact rail keeps code rows contiguous. | Parser/round-trip tests; browser filtering, collapse/expand, precise side selection, layout and deep links. |
12+| WM-03 | Settings modal; bidirectional per-file scrolling, independent mode, clamping without snap-back; persistent preferences with session fallback. | Browser interaction, focus return, reload, unequal widths, independent files, storage write failure and 390px layout. |
13+| WM-04 | Bundled Codex/Claude skill installer with preview, conflict protection, repeatability and symlink rejection. | Unit tests plus actual npm tarballs installed offline into a clean temporary consumer; installed skill/reference read back. Skill validator passed using the existing specify-cli Python environment. |
14+| WM-05 | Claimed coverage distinguished from execution records; source/output identity, stale/unavailable/contradicted states; local locator checks. | Execution, mutation, stale test fixture, missing output, conflicting claims and imported-command tests. |
15+| WM-06 | ESLint JSON and configured checks, complexity/depth suggestions, import, clean-base comparison, expiring suppressions, cancellable watch mode and viewer findings. | Real ESLint process on a temporary repository; committed-base/shifted-line comparison; malformed paths/reports, timeouts/output bounds, cancellation, latest-only watch publication and no command execution during import. |
16+ 
17+## Verification commands
18+ 
19+Run in the repository, sequentially when the browser suite is involved:
20+ 
21+- `npm test` — 102 tests across 13 files, including package installation.
22+- `npm run typecheck` — passed.
23+- `npm run lint` — passed.
24+- `node tests/e2e/improvements.mjs` — new feature browser acceptance suite.
25+- `npm run test:ui` — 38 existing real-browser checks, including file/part apply,
26+ stale-write protection, restoration of `examples/retry.ts`, and zero browser errors.
27+- `npm run build -- --webpack` — production build passed, including on Node 22.23.2.
28+- `git diff --check` — passed.
29+- `quick_validate.py .agents/skills/whymark` — passed with
30+ `~/.local/share/uv/tools/specify-cli/bin/python` (its environment includes PyYAML).
31+ 
32+Node 22.23.2 and Node 24.20.0 were exercised on macOS. Chromium was used for UI
33+checks. Linux/Windows runtime behavior and live agent discovery were not exercised.
34+The source-only skill validator is distinct from packed installation and live discovery.
35+The generated handoff review records executable checks and source fingerprints.
36+ 
37+## Review and limitations
38+ 
39+Source pass: traced parser/serializer compatibility, frontend versus Node import
40+boundaries, code/apply part sharing, local path handling, evidence freshness,
41+installer destinations, and scanner cancellation/baseline semantics.
42+Behavior pass: negative fixtures and public CLI/browser boundaries above. These are
43+separate self-review passes, not an independent-agent audit. The execution records
44+in `reviews/review-improvements.whymark` bind the rerun checks to source bytes.
45+ 
46+- Urgency is canonically emitted as a body field, not a new header attribute:
47+ the baseline v1 formatter demonstrably preserved `urgency: urgent` but drops
48+ unknown header attributes. Updated readers accept both.
49+- Added `dist/**` to ESLint's generated-output exclusions; source remains linted.
50+- A first overlapping run of unit and browser tests saw the browser's temporary
51+ retry-file edit and failed. Rerunning sequentially after restoration passed.
52+ Do not run the mutating browser suite concurrently with unit tests.
53+- Default Turbopack build encountered environment failures (Google Fonts access,
54+ then a subprocess port-binding restriction). The supported webpack build passed;
55+ Turbopack production packaging is not claimed as verified in this environment.
56+- Evidence is unauthenticated, even when hashes match. Fingerprints exclude ignored
57+ files, artifacts and review outputs under `reviews/`, and cannot prove dependency
58+ bytes or external services. Bounds: 50,000 paths, 32 MB/file, 128 MB total; over-limit
59+ source identity is unavailable rather than silently partial.
60+- Baseline comparison requires a clean relevant source checkout, matching resolved
61+ base revision and identical tool commands/versions. No baseline means uncompared
62+ findings. Duplicate identical diagnostics remain potentially ambiguous.
63+- First quality adapter delivery is ESLint plus configured pass/fail checks. Knip,
64+ Semgrep, dependency-audit, coverage/SARIF adapters, authenticated attestations and
65+ human reply threads remain later work, as proposed in the roadmap.
66+- npm installation works from the locally packed artifact. This version has not been
67+ published; no global agent installation or deployment was performed.
intentnormalconfidence unknown

src/cli/quality.ts

+79 0read-only1100%
@@ -0,0 +1,79 @@
1+import { readFileSync, writeFileSync } from "node:fs";
2+import { join, relative, resolve } from "node:path";
3+import { parseWhymark } from "../lib/whymark/parse";
4+import { serializeWhymark } from "../lib/whymark/serialize";
5+import { repoRoot } from "../lib/whymark/git";
6+import { gitHead, reviewFingerprint, sourceFingerprint, writeOutput } from "../lib/whymark/evidence-node";
7+import { compareFindings } from "../lib/quality/baseline";
8+import { parseBoundedJson, parseESLint, readQuality } from "../lib/quality/import";
9+import { readConfig, scanQuality } from "../lib/quality/run";
10+import type { QualityReport } from "../lib/quality/types";
11+ 
12+interface Options { run: boolean; watch: boolean; write: boolean; config?: string; baseline?: string; importPath?: string; toolVersion?: string }
13+export async function qualityCommand(positionals: string[], options: Options) {
14+ const cwd = repoRoot() || process.cwd();
15+ if (positionals[0] === "init") {
16+ const path = join(cwd, "whymark.config.json");
17+ writeFileSync(path, `${JSON.stringify({ version: 1, checks: [
18+ { id: "eslint", format: "eslint", command: "node node_modules/eslint/bin/eslint.js . --format json --rule 'complexity: [warn, 15]' --rule 'max-depth: [warn, 4]'", versionCommand: "node node_modules/eslint/bin/eslint.js --version" },
19+ { id: "typecheck", format: "check", command: "npm run typecheck", versionCommand: "node node_modules/typescript/bin/tsc --version" },
20+ ], suppressions: [] }, null, 2)}\n`, { flag: "wx" });
21+ process.stdout.write(`Created ${path}. Review the commands before running quality --run.\n`);
22+ return;
23+ }
24+ const path = positionals[0];
25+ if (!path || (options.run ? 1 : 0) + (options.importPath ? 1 : 0) !== 1 || options.watch && !options.run) {
26+ throw new Error("Use quality <review.whymark> --run or --import <eslint.json>. See whymark help quality.");
27+ }
28+ let baseline: QualityReport | null = null;
29+ if (options.baseline) {
30+ baseline = readQuality(parseBoundedJson(readFileSync(options.baseline, "utf8")));
31+ if (!baseline) throw new Error("Invalid baseline report.");
32+ }
33+ let controller = new AbortController();
34+ let generation = 0;
35+ const execute = async () => {
36+ const mine = ++generation;
37+ controller.abort(); controller = new AbortController();
38+ const signal = controller.signal;
39+ const text = readFileSync(path, "utf8");
40+ const doc = parseWhymark(text);
41+ let report: QualityReport;
42+ if (options.importPath) {
43+ const input = readFileSync(options.importPath, "utf8");
44+ report = compareFindings({
45+ version: 1, ran: new Date().toISOString(), source: null, sourceAfter: null, head: gitHead(cwd), clean: false, base: doc.meta.base ?? null,
46+ reviewHash: reviewFingerprint(doc), provenance: "imported", comparison: "unavailable",
47+ checks: [{ id: "eslint", version: options.toolVersion ?? "unknown", command: "Imported ESLint JSON (not executed)", status: "unavailable", exitCode: null, ...writeOutput(cwd, input), detail: "Execution and source state were not independently checked." }],
48+ findings: parseESLint(input, name => relative(cwd, resolve(cwd, name)).split("\\").join("/")),
49+ }, null);
50+ } else report = await scanQuality(doc, cwd, readConfig(options.config ?? join(cwd, "whymark.config.json")), { baseline, signal });
51+ if (signal.aborted || mine !== generation) return;
52+ if (options.write) {
53+ if (readFileSync(path, "utf8") !== text) throw new Error("Review changed during the scan; results were not attached. Rerun against the current review.");
54+ doc.meta.extra.quality = report;
55+ writeFileSync(path, serializeWhymark(doc));
56+ }
57+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
58+ if (!options.watch) process.exitCode = report.checks.some(check => check.status !== "pass") || report.findings.some(f => f.severity === "error" && f.status !== "resolved" && !f.suppression) ? 1 : 0;
59+ };
60+ if (!options.watch) { await execute(); return; }
61+ let previous = sourceFingerprint(cwd);
62+ if (!previous) throw new Error("Cannot watch: repository fingerprint unavailable.");
63+ let timer: ReturnType<typeof setTimeout> | undefined;
64+ const launch = () => { void execute().catch(error => process.stderr.write(`${(error as Error).message}\n`)); };
65+ launch();
66+ const interval = setInterval(() => {
67+ const next = sourceFingerprint(cwd);
68+ if (next === previous) return;
69+ previous = next; controller.abort(); generation++;
70+ clearTimeout(timer); timer = setTimeout(launch, 400);
71+ }, 1000);
72+ await new Promise<void>(done => {
73+ const stop = () => {
74+ clearInterval(interval); clearTimeout(timer); controller.abort(); generation++;
75+ process.removeListener("SIGINT", stop); process.removeListener("SIGTERM", stop); done();
76+ };
77+ process.on("SIGINT", stop); process.on("SIGTERM", stop);
78+ });
79+}
intentnormalconfidence unknown

src/components/whymark/evidence-panel.tsx

+24 0read-only1100%
@@ -0,0 +1,24 @@
1+import type { EvidenceView } from "@/lib/whymark/evidence";
2+ 
3+export function EvidencePanel({ evidence }: { evidence: EvidenceView[] }) {
4+ return (
5+ <details aria-label="Execution evidence" className="space-y-2 rounded-lg border border-border/60 p-3 text-xs">
6+ <summary className="cursor-pointer font-medium">Execution evidence · {evidence.length ? `${evidence.length} records` : "none recorded"}</summary>
7+ <p className="text-muted-foreground">Author confidence and passing claims are not measured accuracy. Records are unauthenticated; source hashes establish freshness, not who ran a command.</p>
8+ {!evidence.length ? <p>No execution records. The percentages above count author claims.</p> : evidence.map(({ record, state, contradicted }, index) => (
9+ <details key={index} className="rounded border border-border/50 p-2">
10+ <summary className="cursor-pointer break-all">
11+ <strong>{contradicted ? "Contradicted claim · " : ""}{record.status}</strong> · {state === "current" ? "current source, output checked" : state === "imported" ? "imported, freshness unchecked" : state} · {record.command}
12+ </summary>
13+ <dl className="mt-2 space-y-1 break-all text-muted-foreground">
14+ <div>Recorded: {record.ran} · {record.durationMs}ms · exit {record.exitCode ?? "unavailable"}</div>
15+ <div>{record.tool} · {record.runtime} · cwd {record.cwd}</div>
16+ <div>{record.summary}</div>
17+ <div>Source: {record.source ?? "unavailable"}</div>
18+ <div>Output: {record.outputArtifact} · SHA-256 {record.outputHash}</div>
19+ </dl>
20+ </details>
21+ ))}
22+ </details>
23+ );
24+}
intentnormalconfidence unknown

src/components/whymark/note-disclosure.tsx

+13 0read-only1100%
@@ -0,0 +1,13 @@
1+"use client";
2+ 
3+import { createContext, useContext } from "react";
4+ 
5+export const NoteDisclosure = createContext<{ expanded: (id: string) => boolean; toggle: (id: string) => void }>({
6+ expanded: () => false,
7+ toggle: () => {},
8+});
9+ 
10+export function useNoteDisclosure(id: string) {
11+ const state = useContext(NoteDisclosure);
12+ return { expanded: state.expanded(id), toggle: () => state.toggle(id) };
13+}
intentnormalconfidence unknown

src/components/whymark/quality-panel.tsx

+30 0read-only1100%
@@ -0,0 +1,30 @@
1+"use client";
2+ 
3+import { useState } from "react";
4+import type { QualityReport } from "@/lib/quality/types";
5+import type { FileVM } from "@/lib/view-model";
6+ 
7+export function QualityPanel({ report, state, files }: { report: QualityReport | null; state: "current" | "stale" | "unchecked"; files: FileVM[] }) {
8+ const [changedOnly, setChangedOnly] = useState(false);
9+ if (!report) return null;
10+ const findings = report.findings.filter(finding => !changedOnly || finding.path === null || finding.line === null || files.some(file => file.path === finding.path && file.rows.some(row => row.kind === "add" && row.newLine !== undefined && row.newLine >= finding.line! && row.newLine <= (finding.endLine ?? finding.line!))));
11+ return (
12+ <section aria-label="Code quality" className="mt-3 space-y-2 rounded-lg border border-border/60 p-3 text-xs">
13+ <h2 className="font-medium">Code quality · {state === "unchecked" ? "source unchecked" : `${state} source`}</h2>
14+ <p className="text-muted-foreground">{report.provenance === "imported" ? "Imported report; execution not checked." : "Recorded local checks; unauthenticated evidence."} Baseline comparison {report.comparison}. Static findings do not measure code accuracy.</p>
15+ <ul className="space-y-1">{report.checks.map(check => <li key={check.id}><strong>{check.id} {check.version}: {check.status}</strong><details><summary className="cursor-pointer">Command and output</summary><p className="break-all">{check.command}<br />{check.detail}<br />{check.outputArtifact}<br />SHA-256 {check.outputHash}</p></details></li>)}</ul>
16+ <label className="flex items-center gap-2"><input type="checkbox" checked={changedOnly} onChange={event => setChangedOnly(event.target.checked)} />Changed lines and project findings only</label>
17+ <p>{findings.length} of {report.findings.length} findings shown</p>
18+ <ul className="space-y-2">{findings.map((finding, index) => {
19+ const file = files.findIndex(file => file.path === finding.path);
20+ return <li key={`${finding.id}-${index}`} className="rounded border border-border/50 p-2">
21+ <div className="flex flex-wrap gap-2"><strong>{finding.severity}</strong><span>{finding.status}</span><code>{finding.rule}</code></div>
22+ <p>{finding.message}</p>
23+ {file >= 0 ? <a className="underline" href={`#file-${file}`}>{finding.path}:{finding.line ?? "file"}</a> : <span>{finding.path ?? "project"}:{finding.line ?? "file"}</span>}
24+ {finding.helpUrl ? <a className="ml-2 underline" href={finding.helpUrl} target="_blank" rel="noreferrer">Rule documentation</a> : null}
25+ {finding.suppression ? <p>Suppression: {finding.suppression.reason} · expires {finding.suppression.expires}</p> : null}
26+ </li>;
27+ })}</ul>
28+ </section>
29+ );
30+}
intentnormalconfidence unknown

src/components/whymark/review-settings.tsx

+35 0read-only1100%
@@ -0,0 +1,35 @@
1+"use client";
2+ 
3+import { Settings } from "lucide-react";
4+import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
5+import { DEFAULT_PREFERENCES, type ReviewPreferences } from "@/lib/review-preferences";
6+ 
7+export function ReviewSettings({ value, onChange }: {
8+ value: ReviewPreferences;
9+ onChange: (value: ReviewPreferences) => void;
10+}) {
11+ return (
12+ <Dialog>
13+ <DialogTrigger aria-label="Review settings" className="rounded-md p-1.5 text-muted-foreground hover:bg-foreground/5">
14+ <Settings className="size-4" />
15+ </DialogTrigger>
16+ <DialogContent>
17+ <DialogTitle>Review settings</DialogTitle>
18+ <DialogDescription>Preferences for this browser. Code decisions are kept separate.</DialogDescription>
19+ {([
20+ ["syncScroll", "Synchronize horizontal scrolling"],
21+ ["collapseNotes", "Collapse comments by default"],
22+ ["compactRail", "Compact layout without blank code rows"],
23+ ] as const).map(([key, label]) => (
24+ <label key={key} className="flex items-center gap-3 text-sm">
25+ <input type="checkbox" checked={value[key]} onChange={(event) => onChange({ ...value, [key]: event.target.checked })} />
26+ {label}
27+ </label>
28+ ))}
29+ <button type="button" className="justify-self-start rounded border px-3 py-1.5" onClick={() => onChange({ ...DEFAULT_PREFERENCES })}>
30+ Reset defaults
31+ </button>
32+ </DialogContent>
33+ </Dialog>
34+ );
35+}
intentnormalconfidence unknown

src/components/whymark/use-review-preferences.ts

+27 0read-only1100%
@@ -0,0 +1,27 @@
1+"use client";
2+ 
3+import { useMemo, useSyncExternalStore } from "react";
4+import { PREFERENCES_KEY, parsePreferences, type ReviewPreferences } from "@/lib/review-preferences";
5+ 
6+let sessionValue: string | null = null;
7+let storageFailed = false;
8+const eventName = "whymark-preferences";
9+function subscribe(notify: () => void) {
10+ window.addEventListener("storage", notify);
11+ window.addEventListener(eventName, notify);
12+ return () => { window.removeEventListener("storage", notify); window.removeEventListener(eventName, notify); };
13+}
14+function snapshot() {
15+ if (storageFailed) return sessionValue;
16+ try { return localStorage.getItem(PREFERENCES_KEY); } catch { storageFailed = true; return sessionValue; }
17+}
18+function update(value: ReviewPreferences) {
19+ sessionValue = JSON.stringify(value);
20+ try { localStorage.setItem(PREFERENCES_KEY, sessionValue); } catch { storageFailed = true; }
21+ window.dispatchEvent(new Event(eventName));
22+}
23+export function useReviewPreferences(): [ReviewPreferences, (value: ReviewPreferences) => void] {
24+ const raw = useSyncExternalStore(subscribe, snapshot, () => null);
25+ const value = useMemo(() => parsePreferences(raw), [raw]);
26+ return [value, update];
27+}
intentnormalconfidence unknown

src/lib/quality/baseline.ts

+39 0read-only1100%
@@ -0,0 +1,39 @@
1+import { sha256 } from "../whymark/evidence-node";
2+import type { QualityFinding, QualityReport } from "./types";
3+ 
4+export function identifyFindings(findings: QualityFinding[]): QualityFinding[] {
5+ const counts = new Map<string, number>();
6+ return findings.map(finding => {
7+ const key = JSON.stringify([finding.tool, finding.rule, finding.path, finding.message]);
8+ const occurrence = counts.get(key) ?? 0;
9+ counts.set(key, occurrence + 1);
10+ return { ...finding, id: sha256(`${key}:${occurrence}`) };
11+ });
12+}
13+ 
14+export function compareFindings(current: QualityReport, baseline: QualityReport | null, renames: Map<string, string> = new Map()): QualityReport {
15+ const usable = baseline && baseline.clean && current.base && baseline.head &&
16+ current.base.endsWith(baseline.head) && baseline.source === baseline.sourceAfter && baseline.source !== null &&
17+ current.source === current.sourceAfter && current.source !== null &&
18+ current.checks.every(check => check.status !== "unavailable" && baseline.checks.some(old => old.id === check.id && old.version === check.version && old.command === check.command && old.status !== "unavailable"));
19+ if (!usable) return { ...current, comparison: "unavailable", findings: identifyFindings(current.findings).map(finding => ({ ...finding, status: "uncompared" })) };
20+ const old = identifyFindings(baseline.findings.filter(finding => finding.status !== "resolved").map(finding => ({ ...finding, path: finding.path ? renames.get(finding.path) ?? finding.path : null })));
21+ const pending = new Map(old.map(finding => [finding.id, finding]));
22+ const findings: QualityFinding[] = identifyFindings(current.findings).map(finding => {
23+ const existing = pending.delete(finding.id);
24+ return { ...finding, status: existing ? "existing" as const : "new" as const };
25+ });
26+ for (const finding of pending.values()) {
27+ if (current.checks.some(check => check.id === finding.tool)) findings.push({ ...finding, status: "resolved" });
28+ }
29+ return { ...current, comparison: "available", findings };
30+}
31+ 
32+export function applySuppressions(findings: QualityFinding[], suppressions: Array<{ id: string; reason: string; expires: string }>, now = Date.now()): QualityFinding[] {
33+ return findings.map(finding => {
34+ const match = suppressions.find(item => item.id === finding.id && item.reason.trim() && Date.parse(item.expires) > now);
35+ const clean = { ...finding };
36+ delete clean.suppression;
37+ return match ? { ...clean, suppression: { reason: match.reason, expires: match.expires } } : clean;
38+ });
39+}
intentnormalconfidence unknown

src/lib/quality/import.ts

+62 0read-only1100%
@@ -0,0 +1,62 @@
1+import type { QualityFinding, QualityReport } from "./types";
2+ 
3+const MAX_BYTES = 8 * 1024 * 1024;
4+export function parseBoundedJson(text: string): unknown {
5+ if (new TextEncoder().encode(text).length > MAX_BYTES) throw new Error("Quality report exceeds 8 MB.");
6+ return JSON.parse(text);
7+}
8+ 
9+export function safePath(path: string): boolean {
10+ return path.length > 0 && !path.startsWith("/") && !/^[A-Za-z]:/.test(path) && !path.includes("\\") && !path.split("/").includes("..") && !/[\0-\x1f]/.test(path);
11+}
12+ 
13+export function readQuality(value: unknown): QualityReport | null {
14+ if (!value || typeof value !== "object") return null;
15+ const r = value as QualityReport;
16+ const hash = (v: unknown) => typeof v === "string" && /^[a-f0-9]{64}$/.test(v);
17+ const nullableHash = (v: unknown) => v === null || hash(v);
18+ if (r.version !== 1 || typeof r.clean !== "boolean" || typeof r.ran !== "string" || !Number.isFinite(Date.parse(r.ran)) ||
19+ !nullableHash(r.source) || !nullableHash(r.sourceAfter) || !hash(r.reviewHash) ||
20+ !(r.head === null || typeof r.head === "string") || !(r.base === null || typeof r.base === "string") ||
21+ !["local-run", "imported"].includes(r.provenance) || !["available", "unavailable"].includes(r.comparison) ||
22+ !Array.isArray(r.checks) || r.checks.length > 100 || !Array.isArray(r.findings) || r.findings.length > 20000) return null;
23+ if (!r.checks.every(c => c && typeof c.id === "string" && typeof c.version === "string" &&
24+ typeof c.command === "string" && ["pass", "fail", "unavailable"].includes(c.status) &&
25+ (c.exitCode === null || Number.isInteger(c.exitCode)) && hash(c.outputHash) &&
26+ typeof c.outputArtifact === "string" && typeof c.detail === "string")) return null;
27+ if (!r.findings.every(f => f && typeof f.id === "string" && typeof f.tool === "string" &&
28+ typeof f.rule === "string" && typeof f.message === "string" &&
29+ ["warning", "error"].includes(f.severity) && (f.path === null || typeof f.path === "string" && safePath(f.path)) &&
30+ (f.line === null || Number.isInteger(f.line) && f.line > 0) &&
31+ (f.endLine === null || Number.isInteger(f.endLine) && f.endLine >= (f.line ?? 1)) &&
32+ (f.helpUrl === undefined || typeof f.helpUrl === "string" && /^https?:\/\//.test(f.helpUrl)) &&
33+ ["new", "existing", "resolved", "uncompared"].includes(f.status) &&
34+ (f.suppression === undefined || f.suppression !== null && typeof f.suppression === "object" && typeof f.suppression.reason === "string" && !!f.suppression.reason.trim() && typeof f.suppression.expires === "string" && Number.isFinite(Date.parse(f.suppression.expires))))) return null;
35+ return r;
36+}
37+ 
38+export interface ESLintFile {
39+ filePath: string;
40+ messages: Array<{ ruleId: string | null; severity: number; message: string; line?: number; endLine?: number }>;
41+}
42+ 
43+export function parseESLint(text: string, relativePath: (path: string) => string): QualityFinding[] {
44+ const input = parseBoundedJson(text);
45+ if (!Array.isArray(input) || input.length > 20000) throw new Error("Expected an ESLint JSON array.");
46+ const findings: QualityFinding[] = [];
47+ for (const file of input) {
48+ if (!file || typeof file.filePath !== "string" || !Array.isArray(file.messages)) throw new Error("Malformed ESLint file result.");
49+ const path = relativePath(file.filePath);
50+ if (!safePath(path)) throw new Error("ESLint result path escapes the repository.");
51+ for (const message of file.messages) {
52+ if (!message || typeof message.message !== "string" || ![1, 2].includes(message.severity) || !(message.ruleId === null || typeof message.ruleId === "string")) throw new Error("Malformed ESLint message.");
53+ if (message.line !== undefined && (!Number.isInteger(message.line) || message.line < 1)) throw new Error("Invalid finding line.");
54+ if (message.endLine !== undefined && (!Number.isInteger(message.endLine) || message.endLine < (message.line ?? 1))) throw new Error("Invalid finding range.");
55+ findings.push({ id: "", tool: "eslint", rule: message.ruleId ?? "parse-error", severity: message.severity === 2 ? "error" : "warning", message: message.message, path, line: message.line ?? null, endLine: message.endLine ?? message.line ?? null, status: "uncompared",
56+ ...(message.ruleId && /^[a-z-]+$/.test(message.ruleId) ? { helpUrl: `https://eslint.org/docs/latest/rules/${message.ruleId}` } : {}),
57+ });
58+ if (findings.length > 20000) throw new Error("Too many quality findings.");
59+ }
60+ }
61+ return findings;
62+}
intentnormalconfidence unknown

src/lib/quality/run.ts

+96 0read-only1100%
@@ -0,0 +1,96 @@
1+import { spawn } from "node:child_process";
2+import { execFileSync } from "node:child_process";
3+import { readFileSync, realpathSync } from "node:fs";
4+import { relative, resolve } from "node:path";
5+import type { WhymarkDocument } from "../whymark/types";
6+import { cleanSource, resolveRevision, gitHead, reviewFingerprint, sourceFingerprint, writeOutput } from "../whymark/evidence-node";
7+import { applySuppressions, compareFindings } from "./baseline";
8+import { parseBoundedJson, parseESLint } from "./import";
9+import type { QualityReport } from "./types";
10+ 
11+export interface QualityConfig {
12+ version: 1;
13+ checks: Array<{ id: string; command: string; versionCommand: string; format: "eslint" | "check" }>;
14+ suppressions?: Array<{ id: string; reason: string; expires: string }>;
15+}
16+ 
17+export function readConfig(path: string): QualityConfig {
18+ const value = parseBoundedJson(readFileSync(path, "utf8")) as QualityConfig;
19+ if (!value || value.version !== 1 || !Array.isArray(value.checks) || !value.checks.length || value.checks.length > 20 ||
20+ !value.checks.every(c => c && typeof c.id === "string" && /^[a-z][a-z0-9-]*$/.test(c.id) && typeof c.command === "string" && c.command.length > 0 && c.command.length < 10000 && typeof c.versionCommand === "string" && c.versionCommand.length > 0 && c.versionCommand.length < 10000 && ["eslint", "check"].includes(c.format)) ||
21+ new Set(value.checks.map(c => c.id)).size !== value.checks.length) throw new Error("Invalid whymark.config.json checks.");
22+ if (value.suppressions !== undefined && (!Array.isArray(value.suppressions) || !value.suppressions.every(s => s && typeof s.id === "string" && typeof s.reason === "string" && s.reason.trim() && typeof s.expires === "string" && Number.isFinite(Date.parse(s.expires))))) throw new Error("Invalid quality suppressions.");
23+ return value;
24+}
25+ 
26+export function runTool(command: string, cwd: string, signal?: AbortSignal, timeoutMs = 120000): Promise<{ code: number | null; stdout: string; stderr: string; unavailable: boolean }> {
27+ return new Promise(resolveResult => {
28+ if (signal?.aborted) { resolveResult({ code: null, stdout: "", stderr: "Cancelled", unavailable: true }); return; }
29+ const child = spawn(command, { cwd, shell: true, detached: process.platform !== "win32", env: { ...process.env, CI: "1", NO_COLOR: "1" }, stdio: ["ignore", "pipe", "pipe"] });
30+ let stdout = ""; let stderr = ""; let size = 0; let unavailable = false;
31+ const stop = (reason: string) => {
32+ unavailable = true;
33+ stderr += `\n${reason}`;
34+ try { if (child.pid && process.platform !== "win32") process.kill(-child.pid, "SIGKILL"); else child.kill("SIGKILL"); } catch { /* Already exited. */ }
35+ };
36+ const abort = () => stop("Cancelled");
37+ signal?.addEventListener("abort", abort, { once: true });
38+ const timeout = setTimeout(() => stop("Timed out"), timeoutMs);
39+ child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8");
40+ const append = (text: string, error: boolean) => {
41+ size += Buffer.byteLength(text);
42+ if (size > 8 * 1024 * 1024) { if (!unavailable) stop("Output exceeds 8 MB"); return; }
43+ if (error) stderr += text; else stdout += text;
44+ };
45+ child.stdout.on("data", text => append(text, false));
46+ child.stderr.on("data", text => append(text, true));
47+ child.on("error", error => { unavailable = true; stderr += error.message; });
48+ child.on("close", code => {
49+ clearTimeout(timeout); signal?.removeEventListener("abort", abort);
50+ resolveResult({ code, stdout, stderr, unavailable: unavailable || code === null || code === 127 });
51+ });
52+ });
53+}
54+ 
55+export async function scanQuality(doc: WhymarkDocument, cwd: string, config: QualityConfig, options: { baseline?: QualityReport | null; signal?: AbortSignal } = {}): Promise<QualityReport> {
56+ cwd = realpathSync(cwd);
57+ const report: QualityReport = {
58+ version: 1, ran: new Date().toISOString(), source: sourceFingerprint(cwd), sourceAfter: null,
59+ head: gitHead(cwd), clean: cleanSource(cwd), base: resolveRevision(cwd, doc.meta.base), reviewHash: reviewFingerprint(doc),
60+ provenance: "local-run", comparison: "unavailable", checks: [], findings: [],
61+ };
62+ for (const check of config.checks) {
63+ if (options.signal?.aborted) break;
64+ const version = await runTool(check.versionCommand, cwd, options.signal, 10000);
65+ const run = await runTool(check.command, cwd, options.signal);
66+ let unavailable = run.unavailable || version.unavailable || version.code !== 0;
67+ let detail = run.stderr.trim().slice(0, 1000);
68+ if (check.format === "eslint" && !unavailable) {
69+ if (run.code !== 0 && run.code !== 1) unavailable = true;
70+ else try {
71+ const findings = parseESLint(run.stdout, path => relative(cwd, resolve(cwd, path)).split("\\").join("/"));
72+ if (run.code === 1 && !findings.length) throw new Error("ESLint failed without findings.");
73+ report.findings.push(...findings.map(finding => ({ ...finding, tool: check.id })));
74+ } catch (error) { unavailable = true; detail = (error as Error).message; }
75+ }
76+ report.checks.push({ id: check.id, version: version.stdout.trim().slice(0, 200) || "unavailable", command: check.command,
77+ status: unavailable ? "unavailable" : run.code === 0 ? "pass" : "fail", exitCode: run.code,
78+ ...writeOutput(cwd, `${run.stdout}\n${run.stderr}`), detail,
79+ });
80+ }
81+ report.sourceAfter = sourceFingerprint(cwd);
82+ const renames = new Map<string, string>();
83+ const base = options.baseline?.head;
84+ if (base && /^[a-f0-9]{40}$/.test(base)) {
85+ try {
86+ const tokens = execFileSync("git", ["diff", "--name-status", "-z", "--find-renames", base, "--"], { cwd, encoding: "utf8" }).split("\0");
87+ for (let i = 0; i < tokens.length;) {
88+ const status = tokens[i++]; const old = tokens[i++];
89+ if (status?.startsWith("R")) renames.set(old, tokens[i++]);
90+ }
91+ } catch { /* Baseline comparison remains explicit. */ }
92+ }
93+ const compared = compareFindings(report, options.baseline ?? null, renames);
94+ compared.findings = applySuppressions(compared.findings, config.suppressions ?? []);
95+ return compared;
96+}
intentnormalconfidence unknown

src/lib/quality/types.ts

+39 0read-only1100%
@@ -0,0 +1,39 @@
1+export interface QualityFinding {
2+ id: string;
3+ tool: string;
4+ rule: string;
5+ severity: "warning" | "error";
6+ message: string;
7+ path: string | null;
8+ line: number | null;
9+ endLine: number | null;
10+ helpUrl?: string;
11+ status: "new" | "existing" | "resolved" | "uncompared";
12+ suppression?: { reason: string; expires: string };
13+}
14+ 
15+export interface QualityCheck {
16+ id: string;
17+ version: string;
18+ command: string;
19+ status: "pass" | "fail" | "unavailable";
20+ exitCode: number | null;
21+ outputHash: string;
22+ outputArtifact: string;
23+ detail: string;
24+}
25+ 
26+export interface QualityReport {
27+ version: 1;
28+ ran: string;
29+ source: string | null;
30+ sourceAfter: string | null;
31+ head: string | null;
32+ clean: boolean;
33+ base: string | null;
34+ reviewHash: string;
35+ provenance: "local-run" | "imported";
36+ comparison: "available" | "unavailable";
37+ checks: QualityCheck[];
38+ findings: QualityFinding[];
39+}
intentnormalconfidence unknown

src/lib/review-preferences.ts

+29 0read-only1100%
@@ -0,0 +1,29 @@
1+export interface ReviewPreferences {
2+ version: 1;
3+ syncScroll: boolean;
4+ collapseNotes: boolean;
5+ compactRail: boolean;
6+}
7+ 
8+export const DEFAULT_PREFERENCES: ReviewPreferences = {
9+ version: 1,
10+ syncScroll: true,
11+ collapseNotes: true,
12+ compactRail: true,
13+};
14+export const PREFERENCES_KEY = "whymark.preferences.v1";
15+ 
16+export function parsePreferences(raw: string | null): ReviewPreferences {
17+ try {
18+ const value = JSON.parse(raw ?? "null");
19+ if (value?.version !== 1) return { ...DEFAULT_PREFERENCES };
20+ return {
21+ version: 1,
22+ syncScroll: typeof value.syncScroll === "boolean" ? value.syncScroll : true,
23+ collapseNotes: typeof value.collapseNotes === "boolean" ? value.collapseNotes : true,
24+ compactRail: typeof value.compactRail === "boolean" ? value.compactRail : true,
25+ };
26+ } catch {
27+ return { ...DEFAULT_PREFERENCES };
28+ }
29+}
intentnormalconfidence unknown

src/lib/skill/install.ts

+67 0read-only1100%
@@ -0,0 +1,67 @@
1+import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
2+import { dirname, join, relative, resolve, sep } from "node:path";
3+import { homedir } from "node:os";
4+ 
5+export interface InstallOptions {
6+ packageRoot: string;
7+ cwd: string;
8+ agents?: string[];
9+ global?: boolean;
10+ home?: string;
11+ force?: boolean;
12+ dryRun?: boolean;
13+}
14+ 
15+export function installSkill(options: InstallOptions): Array<{ path: string; action: string }> {
16+ const agents = [...new Set(options.agents?.length ? options.agents : ["codex"])];
17+ if (agents.some(agent => !["codex", "claude-code"].includes(agent))) {
18+ throw new Error("Supported agents: codex, claude-code.");
19+ }
20+ const base = realpathSync(options.global ? options.home ?? homedir() : options.cwd);
21+ const skill = readFileSync(join(options.packageRoot, ".agents/skills/whymark/SKILL.md"), "utf8")
22+ .replaceAll("spec/whymark-v1.md", "references/whymark-v1.md");
23+ const assets = [
24+ ["SKILL.md", skill],
25+ ["references/whymark-v1.md", readFileSync(join(options.packageRoot, "spec/whymark-v1.md"), "utf8")],
26+ ];
27+ const pending: Array<{ path: string; content: string; action: string }> = [];
28+ for (const agent of agents) {
29+ const destination = join(base, agent === "codex" ? ".agents" : ".claude", "skills/whymark");
30+ for (const [name, content] of assets) {
31+ const path = resolve(destination, name);
32+ assertNoSymlinks(base, path);
33+ const identical = existsSync(path) && readFileSync(path, "utf8") === content;
34+ if (!identical && existsSync(path) && !options.force) {
35+ throw new Error(`Refusing to overwrite ${path}. Use --force to replace this skill explicitly.`);
36+ }
37+ pending.push({ path, content, action: identical ? "unchanged" : existsSync(path) ? "replace" : "create" });
38+ }
39+ }
40+ if (!options.dryRun) {
41+ for (const file of pending) {
42+ if (file.action === "unchanged") continue;
43+ assertNoSymlinks(base, file.path);
44+ mkdirSync(dirname(file.path), { recursive: true });
45+ writeFileSync(file.path, file.content, { flag: file.action === "create" ? "wx" : "w" });
46+ }
47+ }
48+ return pending.map(({ path, action }) => ({ path, action }));
49+}
50+ 
51+function assertNoSymlinks(base: string, path: string) {
52+ const parts = relative(base, path).split(sep);
53+ if (parts.includes("..")) throw new Error("Skill destination escapes its installation directory.");
54+ let current = base;
55+ for (const [index, part] of parts.entries()) {
56+ current = join(current, part);
57+ let info;
58+ try { info = lstatSync(current); } catch (error) {
59+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
60+ throw error;
61+ }
62+ if (info.isSymbolicLink()) throw new Error(`Refusing symlink destination ${current}.`);
63+ if (index < parts.length - 1 ? !info.isDirectory() : !info.isFile()) {
64+ throw new Error(`Unexpected destination type at ${current}.`);
65+ }
66+ }
67+}
intentnormalconfidence unknown

src/lib/whymark/evidence-node.ts

+101 0read-only1100%
@@ -0,0 +1,101 @@
1+import { createHash } from "node:crypto";
2+import { execFileSync } from "node:child_process";
3+import { lstatSync, readFileSync, readlinkSync, realpathSync, mkdirSync, writeFileSync } from "node:fs";
4+import { join, relative, resolve, sep } from "node:path";
5+import type { WhymarkDocument } from "./types";
6+import { evidenceViews, type ExecutionEvidence } from "./evidence";
7+ 
8+export const sha256 = (text: string | Buffer) => createHash("sha256").update(text).digest("hex");
9+ 
10+export function gitHead(cwd: string): string | null {
11+ try { return execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return null; }
12+}
13+ 
14+function excluded(path: string) {
15+ return (path.startsWith("reviews/") && (path.endsWith(".whymark") || path.endsWith(".quality.json"))) || path.startsWith(".artifacts/");
16+}
17+ 
18+export function cleanSource(cwd: string): boolean {
19+ try {
20+ const entries = execFileSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd, encoding: "utf8" }).split("\0").filter(Boolean);
21+ for (let i = 0; i < entries.length; i++) {
22+ const entry = entries[i];
23+ if (!excluded(entry.slice(3))) return false;
24+ if (/[RC]/.test(entry.slice(0, 2)) && !excluded(entries[++i] ?? "")) return false;
25+ }
26+ return true;
27+ } catch { return false; }
28+}
29+ 
30+export function resolveRevision(cwd: string, value: string | undefined): string | null {
31+ const hash = value?.match(/(?:^|@)([a-f0-9]{7,40})$/)?.[1];
32+ if (!hash) return null;
33+ try { return execFileSync("git", ["rev-parse", "--verify", `${hash}^{commit}`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return null; }
34+}
35+ 
36+export function sourceFingerprint(cwd: string): string | null {
37+ try {
38+ const names = execFileSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { cwd, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
39+ const paths = [...new Set(names.split("\0").filter(Boolean))].sort();
40+ if (paths.length > 50000) return null;
41+ const digest = createHash("sha256").update("whymark-source-v1\0").update(gitHead(cwd) ?? "unborn");
42+ let bytes = 0;
43+ for (const path of paths) {
44+ if (excluded(path)) continue;
45+ digest.update(`\0${path}\0`);
46+ try {
47+ const info = lstatSync(join(cwd, path));
48+ digest.update(String(info.mode));
49+ if (info.isSymbolicLink()) digest.update(`link:${readlinkSync(join(cwd, path))}`);
50+ else if (info.isFile()) {
51+ bytes += info.size;
52+ if (info.size > 32 * 1024 * 1024 || bytes > 128 * 1024 * 1024) return null;
53+ digest.update(readFileSync(join(cwd, path)));
54+ } else return null;
55+ } catch (error) {
56+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") return null;
57+ digest.update("missing");
58+ }
59+ }
60+ return digest.digest("hex");
61+ } catch { return null; }
62+}
63+ 
64+export function reviewFingerprint(doc: WhymarkDocument): string {
65+ return sha256(JSON.stringify({ base: doc.meta.base, head: doc.meta.head, files: doc.files.map(file => ({ path: file.path, oldSha: file.oldSha, newSha: file.newSha, hunks: file.hunks.map(hunk => ({ oldStart: hunk.oldStart, newStart: hunk.newStart, heading: hunk.heading, lines: hunk.lines })) })) }));
66+}
67+ 
68+export function writeOutput(cwd: string, output: string): { outputHash: string; outputArtifact: string } {
69+ const outputHash = sha256(output);
70+ const outputArtifact = `.artifacts/whymark/${outputHash}.log`;
71+ const root = realpathSync(cwd);
72+ const directory = join(root, ".artifacts/whymark");
73+ for (const part of [join(root, ".artifacts"), directory]) {
74+ try { if (lstatSync(part).isSymbolicLink()) throw new Error("Evidence artifact directory is a symlink."); }
75+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
76+ }
77+ mkdirSync(directory, { recursive: true });
78+ const path = join(root, outputArtifact);
79+ try { writeFileSync(path, output, { flag: "wx" }); }
80+ catch (error) {
81+ if ((error as NodeJS.ErrnoException).code !== "EEXIST" || lstatSync(path).isSymbolicLink() || sha256(readFileSync(path)) !== outputHash) throw error;
82+ }
83+ return { outputHash, outputArtifact };
84+}
85+ 
86+export function localEvidence(doc: WhymarkDocument, cwd: string) {
87+ return evidenceViews(doc, {
88+ source: sourceFingerprint(cwd), reviewHash: reviewFingerprint(doc),
89+ outputAvailable: (record: ExecutionEvidence) => {
90+ try {
91+ if (!/^\.artifacts\/whymark\/[a-f0-9]{64}\.log$/.test(record.outputArtifact)) return false;
92+ const root = realpathSync(cwd);
93+ const path = realpathSync(resolve(root, record.outputArtifact));
94+ const rel = relative(root, path);
95+ if (rel === ".." || rel.startsWith(`..${sep}`)) return false;
96+ if (lstatSync(path).size > 32 * 1024 * 1024) return false;
97+ return sha256(readFileSync(path)) === record.outputHash;
98+ } catch { return false; }
99+ },
100+ });
101+}
intentnormalconfidence unknown

src/lib/whymark/evidence.ts

+68 0read-only1100%
@@ -0,0 +1,68 @@
1+import type { WhymarkDocument, VerifyStatus } from "./types";
2+ 
3+export interface ExecutionEvidence {
4+ version: 1;
5+ command: string;
6+ noteId: string | null;
7+ file: string | null;
8+ claimed: VerifyStatus;
9+ status: "pass" | "fail" | "unavailable";
10+ ran: string;
11+ durationMs: number;
12+ exitCode: number | null;
13+ cwd: string;
14+ tool: string;
15+ runtime: string;
16+ source: string | null;
17+ sourceAfter: string | null;
18+ head: string | null;
19+ base: string | null;
20+ reviewHash: string;
21+ outputHash: string;
22+ outputArtifact: string;
23+ summary: string;
24+}
25+ 
26+export type EvidenceState = "imported" | "current" | "stale" | "unavailable";
27+export interface EvidenceView {
28+ record: ExecutionEvidence;
29+ state: EvidenceState;
30+ contradicted: boolean;
31+}
32+ 
33+const hash = (value: unknown) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
34+const nullableString = (value: unknown) => value === null || typeof value === "string";
35+const status = (value: unknown) => ["pass", "fail", "unknown", "skipped"].includes(String(value));
36+ 
37+export function readEvidence(value: unknown): ExecutionEvidence[] {
38+ if (!Array.isArray(value) || value.length > 1000) return [];
39+ return value.filter((r): r is ExecutionEvidence => r && typeof r === "object" &&
40+ r.version === 1 && typeof r.command === "string" && r.command.length <= 10000 &&
41+ nullableString(r.noteId) && nullableString(r.file) && status(r.claimed) &&
42+ ["pass", "fail", "unavailable"].includes(r.status) &&
43+ typeof r.ran === "string" && Number.isFinite(Date.parse(r.ran)) &&
44+ Number.isFinite(r.durationMs) && r.durationMs >= 0 &&
45+ (r.exitCode === null || Number.isInteger(r.exitCode)) &&
46+ (r.status !== "pass" || r.exitCode === 0) &&
47+ typeof r.cwd === "string" && typeof r.tool === "string" && typeof r.runtime === "string" &&
48+ (r.source === null || hash(r.source)) && (r.sourceAfter === null || hash(r.sourceAfter)) &&
49+ nullableString(r.head) && nullableString(r.base) && hash(r.reviewHash) && hash(r.outputHash) &&
50+ typeof r.outputArtifact === "string" && typeof r.summary === "string");
51+}
52+ 
53+export function evidenceViews(doc: WhymarkDocument, local?: {
54+ source: string | null;
55+ reviewHash: string;
56+ outputAvailable: (record: ExecutionEvidence) => boolean;
57+}): EvidenceView[] {
58+ return readEvidence(doc.meta.extra.evidence).map(record => {
59+ let state: EvidenceState = "imported";
60+ if (record.status === "unavailable" || !record.source || !record.sourceAfter) state = "unavailable";
61+ else if (record.source !== record.sourceAfter) state = "stale";
62+ else if (local) {
63+ if (!local.source || !local.outputAvailable(record)) state = "unavailable";
64+ else state = record.source === local.source && record.reviewHash === local.reviewHash ? "current" : "stale";
65+ }
66+ return { record, state, contradicted: (record.claimed === "pass" || record.claimed === "fail") && record.status !== "unavailable" && record.status !== record.claimed };
67+ });
68+}
intentnormalconfidence unknown

tests/e2e/improvements.mjs

+111 0read-only1100%
@@ -0,0 +1,111 @@
1+import assert from "node:assert/strict";
2+import { copyFileSync, mkdirSync, rmSync } from "node:fs";
3+import { chromium } from "playwright";
4+ 
5+const base = process.env.WHYMARK_URL ?? "http://127.0.0.1:43917";
6+const out = process.env.WHYMARK_ARTIFACTS ?? "/tmp/whymark-improvements";
7+const fixture = "reviews/ui-improvements-fixture.whymark";
8+mkdirSync(out, { recursive: true });
9+copyFileSync("tests/fixtures/multiple-notes.whymark", fixture);
10+const browser = await chromium.launch();
11+try {
12+ const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
13+ await page.goto(`${base}/r/ui-improvements-fixture`, { waitUntil: "networkidle" });
14+ const row = page.locator('[data-whymark-body="unified"] .whymark-row[data-notes~="security"]').first();
15+ const background = await row.evaluate(el => getComputedStyle(el).backgroundColor);
16+ await page.locator('#note-security').hover();
17+ await page.screenshot({ path: `${out}/hover.png` });
18+ assert.equal(await row.evaluate(el => getComputedStyle(el).backgroundColor), background, "security hover must preserve addition background");
19+ console.log("PASS semantic hover colors");
20+ assert.equal(await page.locator('#note-security button[aria-expanded]').getAttribute('aria-expanded'), 'false');
21+ assert.match(await page.locator('#note-security').innerText(), /urgent/);
22+ assert.match(await page.locator('#note-security').innerText(), /80% confidence/);
23+ await row.getByRole('button', { name: '3 comments on line 1' }).click();
24+ const chooser = page.getByRole('navigation', { name: 'Comments on selected line' });
25+ assert.equal(await chooser.getByRole('button').count(), 3);
26+ await chooser.getByRole('button', { name: /intent/ }).click();
27+ assert.equal(await page.locator('#note-intent button[aria-expanded]').getAttribute('aria-expanded'), 'true');
28+ await page.getByRole('button', { name: 'Collapse comment intent' }).click();
29+ assert.equal(await page.locator('#note-intent button[aria-expanded]').getAttribute('aria-expanded'), 'false');
30+ await page.getByRole('button', { name: 'Expand all', exact: true }).click();
31+ assert.equal(await page.locator('[aria-expanded="false"][aria-controls^="comment-body-"]').count(), 0);
32+ assert.equal(await page.locator('.whymark-gap').count(), 0);
33+ await page.waitForFunction(() => {
34+ const rects = [...document.querySelectorAll('section#file-0 [id^="note-"]')].map(card => card.getBoundingClientRect()).sort((a,b) => a.top-b.top);
35+ return rects.every((rect, i) => !i || rect.top >= rects[i-1].bottom - 1);
36+ });
37+ const overlap = await page.locator('section#file-0 [id^="note-"]').evaluateAll(cards => {
38+ const rects = cards.map(card => card.getBoundingClientRect()).sort((a, b) => a.top - b.top);
39+ return rects.some((rect, i) => i && rect.top < rects[i - 1].bottom - 1);
40+ });
41+ assert.equal(overlap, false, 'expanded cards must not overlap');
42+ await page.getByRole('button', { name: 'Collapse all', exact: true }).click();
43+ await page.getByRole('button', { name: /^intent\s*1$/ }).click();
44+ const filtered = page.locator('[data-whymark-body="unified"] .whymark-row[data-notes~="intent"]').first();
45+ assert.equal(await filtered.getAttribute('data-notes'), 'intent');
46+ await filtered.getByRole('button', { name: '1 comments on line 1' }).click();
47+ assert.equal(await page.locator('#note-intent button[aria-expanded]').getAttribute('aria-expanded'), 'true');
48+ await page.getByRole('button', { name: /^clear$/ }).click();
49+ console.log('PASS multiple comments, filtering, disclosure and compact layout');
50+ await page.getByRole('button', { name: /^split$/ }).click();
51+ const left = page.locator('[data-whymark-side="del"]').first();
52+ const right = page.locator('[data-whymark-side="add"]').first();
53+ await left.evaluate(el => { el.scrollLeft = 120; });
54+ await page.waitForTimeout(100);
55+ assert.equal(await right.evaluate(el => el.scrollLeft), 120);
56+ await right.evaluate(el => { el.scrollLeft = 60; });
57+ await page.waitForTimeout(100);
58+ assert.equal(await left.evaluate(el => el.scrollLeft), 60);
59+ const settings = page.getByRole('button', { name: 'Review settings', exact: true });
60+ await settings.click();
61+ await page.getByRole('checkbox', { name: 'Synchronize horizontal scrolling' }).uncheck();
62+ await page.keyboard.press('Escape');
63+ await page.waitForFunction(() => document.activeElement?.getAttribute('aria-label') === 'Review settings');
64+ assert.equal(await settings.evaluate(el => el === document.activeElement), true);
65+ await left.evaluate(el => { el.scrollLeft = 200; });
66+ await page.waitForTimeout(100);
67+ assert.equal(await right.evaluate(el => el.scrollLeft), 60);
68+ await settings.click();
69+ await page.getByRole('checkbox', { name: 'Synchronize horizontal scrolling' }).check();
70+ await page.keyboard.press('Escape');
71+ await page.waitForTimeout(100);
72+ assert.equal(await right.evaluate(el => el.scrollLeft), 200);
73+ const secondRight = page.locator('[data-whymark-side="add"]').nth(1);
74+ const secondLeft = page.locator('[data-whymark-side="del"]').nth(1);
75+ await secondRight.evaluate(el => { el.scrollLeft = 250; });
76+ await page.waitForTimeout(100);
77+ assert.equal(await secondLeft.evaluate(el => el.scrollLeft), 0);
78+ assert.equal(await secondRight.evaluate(el => el.scrollLeft), 250, 'short peer must not snap origin backwards');
79+ assert.equal(await right.evaluate(el => el.scrollLeft), 200, 'files must scroll independently');
80+ await page.screenshot({ path: `${out}/split.png` });
81+ await settings.click();
82+ await page.getByRole('checkbox', { name: 'Synchronize horizontal scrolling' }).uncheck();
83+ await page.keyboard.press('Escape');
84+ await page.reload({ waitUntil: 'networkidle' });
85+ await settings.click();
86+ assert.equal(await page.getByRole('checkbox', { name: 'Synchronize horizontal scrolling' }).isChecked(), false);
87+ await page.getByRole('button', { name: 'Reset defaults' }).click();
88+ await page.keyboard.press('Escape');
89+ console.log('PASS bidirectional split sync, setting, persistence and focus return');
90+ await page.setViewportSize({ width: 390, height: 844 });
91+ await page.waitForTimeout(300);
92+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, 'no page overflow on narrow screens');
93+ assert.equal(await page.locator('#note-security').count(), 1);
94+ await page.screenshot({ path: `${out}/narrow.png` });
95+ await page.goto(`${base}/r/ui-improvements-fixture#note-intent`, { waitUntil: 'networkidle' });
96+ assert.equal(await page.locator('#note-intent button[aria-expanded]').getAttribute('aria-expanded'), 'true');
97+ console.log('PASS narrow layout and deep links');
98+ const blocked = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
99+ await blocked.addInitScript(() => { Storage.prototype.setItem = () => { throw new DOMException('Quota exceeded', 'QuotaExceededError'); }; });
100+ await blocked.goto(`${base}/r/ui-improvements-fixture`, { waitUntil: 'networkidle' });
101+ await blocked.getByRole('button', { name: 'Review settings', exact: true }).click();
102+ await blocked.getByRole('checkbox', { name: 'Synchronize horizontal scrolling' }).uncheck();
103+ assert.equal(await blocked.getByRole('checkbox', { name: 'Synchronize horizontal scrolling' }).isChecked(), false);
104+ await blocked.keyboard.press('Escape');
105+ await blocked.getByRole('button', { name: 'Review settings', exact: true }).click();
106+ assert.equal(await blocked.getByRole('checkbox', { name: 'Synchronize horizontal scrolling' }).isChecked(), false);
107+ console.log('PASS storage write failure retains session preferences');
108+} finally {
109+ await browser.close();
110+ rmSync(fixture, { force: true });
111+}
intentnormalconfidence unknown

tests/evidence.test.ts

+81 0read-only1100%
@@ -0,0 +1,81 @@
1+import { execFileSync } from "node:child_process";
2+import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+import { tmpdir } from "node:os";
4+import { join } from "node:path";
5+import { afterEach, describe, expect, it } from "vitest";
6+import { parseWhymark } from "../src/lib/whymark/parse";
7+import { serializeWhymark } from "../src/lib/whymark/serialize";
8+import { verifyDocument } from "../src/lib/whymark/verify";
9+import { evidenceViews, readEvidence } from "../src/lib/whymark/evidence";
10+import { localEvidence, sourceFingerprint } from "../src/lib/whymark/evidence-node";
11+import { hasPassingVerify } from "../src/lib/whymark/stats";
12+import { collectSourceDiagnostics } from "../src/lib/whymark/validate-tree";
13+const dirs: string[] = [];
14+function repo() {
15+ const cwd = mkdtempSync(join(tmpdir(), "whymark-evidence-")); dirs.push(cwd);
16+ execFileSync("git", ["init", "-q"], { cwd });
17+ mkdirSync(join(cwd, "reviews")); mkdirSync(join(cwd, "tests"));
18+ writeFileSync(join(cwd, "file.ts"), "const a = 1;\n");
19+ writeFileSync(join(cwd, "tests/fixture.whymark"), "test input");
20+ return cwd;
21+}
22+afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); });
23+const input = `---
24+whymark: 1
25+title: evidence
26+checks:
27+ - cmd: node -e "console.log('checked')"
28+ status: pass
29+---
30+@file file.ts added +1
31+@@ -0,0 +1 @@
32++const a = 1;
33+@note +1 id=reason
34+why: Preserve an explicit local value.
35+verify: cmd \`node -e "process.exit(1)"\` => pass
36+`;
37+ 
38+describe("verification evidence", () => {
39+ it("records output and source identity, preserves contradiction, detects stale tests", () => {
40+ const cwd = repo(); const doc = parseWhymark(input);
41+ const results = verifyDocument(doc, { cwd, recordEvidence: true, toolVersion: "test" });
42+ expect(results.map(r => r.outcome)).toEqual(["confirmed", "contradicted"]);
43+ const serialized = serializeWhymark(doc);
44+ writeFileSync(join(cwd, "reviews/test.whymark"), serialized);
45+ const reread = parseWhymark(serialized);
46+ expect(readEvidence(reread.meta.extra.evidence)).toHaveLength(2);
47+ expect(localEvidence(reread, cwd).map(r => r.state)).toEqual(["current", "current"]);
48+ expect(localEvidence(reread, cwd)[1].contradicted).toBe(true);
49+ expect(evidenceViews(reread)[0].state).toBe("imported");
50+ writeFileSync(join(cwd, "tests/fixture.whymark"), "changed test input");
51+ expect(localEvidence(reread, cwd).every(r => r.state === "stale")).toBe(true);
52+ });
53+ it("never upgrades a fabricated claim or conflicting claims into fresh proof", () => {
54+ const doc = parseWhymark(input);
55+ expect(evidenceViews(doc)).toEqual([]);
56+ const note = doc.files[0].notes[0];
57+ note.verify.push({ method: "manual", status: "fail", raw: "manual => fail" });
58+ expect(hasPassingVerify(note)).toBe(false);
59+ expect(readEvidence([{ version: 1, status: "pass" }])).toEqual([]);
60+ });
61+ it("treats missing artifacts, timeout and mutation during verification as unavailable/stale", () => {
62+ const cwd = repo(); const doc = parseWhymark(input);
63+ doc.meta.checks = [{ cmd: 'node -e "setTimeout(() => {}, 10000)"', status: "unknown" }]; doc.files[0].notes[0].verify = [];
64+ const results = verifyDocument(doc, { cwd, timeoutMs: 30, recordEvidence: true });
65+ expect(results[0].outcome).toBe("unrunnable");
66+ expect(localEvidence(doc, cwd)[0].state).toBe("unavailable");
67+ doc.meta.checks = [{ cmd: 'node -e "require(\'fs\').writeFileSync(\'file.ts\', \'changed\')"', status: "unknown" }];
68+ verifyDocument(doc, { cwd, recordEvidence: true });
69+ expect(localEvidence(doc, cwd).at(-1)?.state).toBe("stale");
70+ const fresh = parseWhymark(input); verifyDocument(fresh, { cwd, recordEvidence: true });
71+ const record = readEvidence(fresh.meta.extra.evidence)[0];
72+ expect(readFileSync(join(cwd, record.outputArtifact), "utf8")).toContain("checked");
73+ rmSync(join(cwd, record.outputArtifact));
74+ expect(localEvidence(fresh, cwd)[0].state).toBe("unavailable");
75+ });
76+ it("checks local locators without fetching URLs or executing claims", () => {
77+ const cwd = repo(); const doc = parseWhymark(input + '\nsource: file:file.ts:99\nsource: file:../outside\nsource: url:http://127.0.0.1/private\n');
78+ expect(collectSourceDiagnostics(doc, cwd)).toHaveLength(2);
79+ expect(sourceFingerprint(cwd)).toMatch(/^[a-f0-9]{64}$/);
80+ });
81+});
intentnormalconfidence unknown

tests/fixtures/multiple-notes.whymark

+43 0read-only1100%
@@ -0,0 +1,43 @@
1+---
2+whymark: 1
3+title: Multiple comments and stable diff colors
4+scope: manual
5+summary: A fixture for overlapping annotations and horizontal scrolling.
6+---
7+ 
8+@file example.ts modified +3 -3
9+@@ -1,3 +1,3 @@
10+-export const first = "old value with a deliberately long line for horizontal scrolling: abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz";
11+-export const second = 2;
12+-export const third = 3;
13++export const first = "new value with a deliberately long line for horizontal scrolling: abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz";
14++export const second = 20;
15++export const third = 30;
16+ 
17+@note +1 kind=security risk=high confidence=0.8 id=security
18+urgency: urgent
19+why: Validate the new value at the boundary because downstream consumers assume it is trusted.
20+source: inference — fixture only
21+verify: none
22+ 
23+@note +1 kind=intent confidence=0.7 id=intent
24+urgency: normal
25+why: Keep the descriptive value so callers can inspect its purpose before using it.
26+verify: none
27+ 
28+@note +1..2 kind=test id=overlap
29+why: The same value has a second consumer, so the regression needs both call paths.
30+verify: none
31+ 
32+@note -1 kind=security id=old-security
33+why: The removed value was previously treated as trusted without validation.
34+verify: none
35+ 
36+@note +3 kind=note id=last
37+why: Preserve the separate third case so adjacent comment layout remains readable.
38+verify: none
39+ 
40+@file second.ts modified +1 -1
41+@@ -1 +1 @@
42+-export const x = 1;
43++export const x = "long right-only overflow abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz";
intentnormalconfidence unknown

tests/improvements.test.ts

+40 0read-only1100%
@@ -0,0 +1,40 @@
1+import { describe, expect, it } from "vitest";
2+import { readFileSync } from "node:fs";
3+import { parseWhymark } from "../src/lib/whymark/parse";
4+import { serializeWhymark } from "../src/lib/whymark/serialize";
5+import { buildRows } from "../src/lib/whymark/align";
6+import { DEFAULT_PREFERENCES, parsePreferences } from "../src/lib/review-preferences";
7+ 
8+const fixture = readFileSync("tests/fixtures/multiple-notes.whymark", "utf8");
9+ 
10+describe("overlapping annotations and urgency", () => {
11+ it("attaches every annotation to the exact side and range", () => {
12+ const rows = buildRows(parseWhymark(fixture).files[0]);
13+ expect(rows.find(row => row.newLine === 1)?.noteIds).toEqual(["security", "intent", "overlap"]);
14+ expect(rows.find(row => row.oldLine === 1)?.noteIds).toEqual(["old-security"]);
15+ });
16+ it("writes urgency as a v1-compatible field, including header input", () => {
17+ const doc = parseWhymark(fixture.replace("kind=security risk=high", "kind=security urgency=urgent risk=high"));
18+ expect(doc.files[0].notes[0].urgency).toBe("urgent");
19+ const text = serializeWhymark(doc);
20+ expect(text).not.toContain("urgency=");
21+ expect(parseWhymark(text).files[0].notes[0].urgency).toBe("urgent");
22+ });
23+ it("preserves unknown urgency and accepts legacy notes without it", () => {
24+ const doc = parseWhymark(fixture.replace("urgency: urgent", "urgency: eventually"));
25+ expect(doc.files[0].notes[0].urgency).toBeUndefined();
26+ expect(doc.diagnostics.some(item => item.code === "urgency-unknown")).toBe(true);
27+ expect(serializeWhymark(doc)).toContain("urgency: eventually");
28+ expect(doc.files[0].notes[2].urgency).toBeUndefined();
29+ const header = parseWhymark(fixture.replace("kind=security risk=high", "kind=security urgency=future risk=high").replace("urgency: urgent\n", ""));
30+ expect(serializeWhymark(header)).toContain("urgency: future");
31+ });
32+});
33+ 
34+describe("viewer preferences", () => {
35+ it("ignores invalid storage and unrelated keys", () => {
36+ expect(parsePreferences("invalid")).toEqual(DEFAULT_PREFERENCES);
37+ expect(parsePreferences('{"version":2,"syncScroll":false}')).toEqual(DEFAULT_PREFERENCES);
38+ expect(parsePreferences('{"version":1,"syncScroll":false,"code":"private","collapseNotes":"no"}')).toEqual({ ...DEFAULT_PREFERENCES, syncScroll: false });
39+ });
40+});
intentnormalconfidence unknown

tests/package.test.ts

+28 0read-only1100%
@@ -0,0 +1,28 @@
1+import { spawnSync } from "node:child_process";
2+import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+import { tmpdir } from "node:os";
4+import { join, resolve } from "node:path";
5+import { expect, it } from "vitest";
6+ 
7+it("installs the packed skill with only npm tarballs and no Git access", () => {
8+ const temp = mkdtempSync(join(tmpdir(), "whymark-pack-"));
9+ const run = (command: string, args: string[], cwd = resolve(".")) => {
10+ const result = spawnSync(command, args, { cwd, encoding: "utf8", env: { ...process.env, npm_config_cache: join(temp, "cache"), npm_config_offline: "true", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" } });
11+ expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
12+ return result.stdout;
13+ };
14+ try {
15+ const packed = JSON.parse(run("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temp]))[0];
16+ expect(packed.files.map((f: { path: string }) => f.path)).toContain(".agents/skills/whymark/SKILL.md");
17+ const yaml = JSON.parse(run("npm", ["pack", "./node_modules/yaml", "--ignore-scripts", "--json", "--pack-destination", temp]))[0];
18+ const consumer = join(temp, "consumer"); mkdirSync(consumer);
19+ writeFileSync(join(consumer, "package.json"), '{"private":true}');
20+ run("npm", ["install", "--offline", "--ignore-scripts", "--no-audit", "--no-fund", join(temp, packed.filename), join(temp, yaml.filename)], consumer);
21+ const cli = join(consumer, "node_modules/whymark/bin/whymark.mjs");
22+ run(process.execPath, [cli, "skill", "install", "--agent", "codex", "--agent", "claude-code"], consumer);
23+ const skill = join(consumer, ".agents/skills/whymark");
24+ expect(existsSync(join(skill, "references/whymark-v1.md"))).toBe(true);
25+ expect(readFileSync(join(skill, "SKILL.md"), "utf8")).toContain("references/whymark-v1.md");
26+ expect(run(process.execPath, [cli, "skill", "install"], consumer)).toContain("unchanged");
27+ } finally { rmSync(temp, { recursive: true, force: true }); }
28+}, 30000);
intentnormalconfidence unknown

tests/quality-cli.test.ts

+56 0read-only1100%
@@ -0,0 +1,56 @@
1+import { execFileSync, spawn, spawnSync } from "node:child_process";
2+import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+import { tmpdir } from "node:os";
4+import { join, resolve } from "node:path";
5+import { expect, it } from "vitest";
6+const cli = resolve("dist/whymark.mjs");
7+async function until(check: () => boolean, timeout = 12000) {
8+ const start = Date.now();
9+ while (!check()) {
10+ if (Date.now() - start > timeout) throw new Error("Timed out waiting for quality watcher");
11+ await new Promise(done => setTimeout(done, 30));
12+ }
13+}
14+it("cancels superseded scans and publishes only the latest source in watch mode", async () => {
15+ const cwd = mkdtempSync(join(tmpdir(), "whymark-watch-"));
16+ let child: ReturnType<typeof spawn> | undefined;
17+ try {
18+ execFileSync("git", ["init", "-q"], { cwd });
19+ mkdirSync(join(cwd, "reviews")); mkdirSync(join(cwd, ".artifacts"));
20+ writeFileSync(join(cwd, "reviews/check.whymark"), "---\nwhymark: 1\ntitle: watch\n---\n");
21+ writeFileSync(join(cwd, "input.txt"), "old");
22+ writeFileSync(join(cwd, "check.cjs"), `const fs = require('node:fs'); const value = fs.readFileSync('input.txt', 'utf8'); fs.appendFileSync('.artifacts/started', value+'\\n'); setTimeout(() => console.log(value), 2500);`);
23+ writeFileSync(join(cwd, "whymark.config.json"), JSON.stringify({ version: 1, checks: [{ id: "check", format: "check", command: "node check.cjs", versionCommand: "node --version" }] }));
24+ child = spawn(process.execPath, [cli, "quality", "reviews/check.whymark", "--run", "--watch", "--write"], { cwd, stdio: ["ignore", "pipe", "pipe"] });
25+ let output = ""; let error = "";
26+ child.stdout!.on("data", data => { output += data; });
27+ child.stderr!.on("data", data => { error += data; });
28+ await until(() => existsSync(join(cwd, ".artifacts/started")));
29+ writeFileSync(join(cwd, "input.txt"), "new");
30+ await until(() => output.includes('"checks"'));
31+ const report = JSON.parse(output);
32+ expect(error).toBe("");
33+ expect(readFileSync(join(cwd, report.checks[0].outputArtifact), "utf8").trim()).toBe("new");
34+ expect(report.source).toBe(report.sourceAfter);
35+ expect(readFileSync(join(cwd, "reviews/check.whymark"), "utf8")).toContain("quality:");
36+ } finally {
37+ if (child && child.exitCode === null) {
38+ const closed = new Promise(done => child!.once("close", done));
39+ child.kill("SIGTERM"); await closed;
40+ }
41+ rmSync(cwd, { recursive: true, force: true });
42+ }
43+}, 20000);
44+ 
45+it("imports ESLint findings without executing a review's embedded commands", () => {
46+ const cwd = mkdtempSync(join(tmpdir(), "whymark-import-"));
47+ try {
48+ execFileSync("git", ["init", "-q"], { cwd }); mkdirSync(join(cwd, "reviews"));
49+ writeFileSync(join(cwd, "reviews/check.whymark"), '---\nwhymark: 1\ntitle: import\nchecks:\n - cmd: touch unexpected-execution\n status: pass\n---\n');
50+ writeFileSync(join(cwd, "eslint.json"), JSON.stringify([{ filePath: "a.js", messages: [{ ruleId: "complexity", severity: 1, message: "Complexity is 20", line: 1 }] }]));
51+ const result = spawnSync(process.execPath, [cli, "quality", "reviews/check.whymark", "--import", "eslint.json", "--write"], { cwd, encoding: "utf8" });
52+ expect(result.status).toBe(1);
53+ expect(JSON.parse(result.stdout).provenance).toBe("imported");
54+ expect(existsSync(join(cwd, "unexpected-execution"))).toBe(false);
55+ } finally { rmSync(cwd, { recursive: true, force: true }); }
56+});
intentnormalconfidence unknown

tests/quality.test.ts

+89 0read-only1100%
@@ -0,0 +1,89 @@
1+import { execFileSync } from "node:child_process";
2+import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3+import { tmpdir } from "node:os";
4+import { join, resolve } from "node:path";
5+import { afterEach, describe, expect, it } from "vitest";
6+import { parseESLint, parseBoundedJson, readQuality } from "../src/lib/quality/import";
7+import { applySuppressions, compareFindings, identifyFindings } from "../src/lib/quality/baseline";
8+import { readConfig, runTool, scanQuality } from "../src/lib/quality/run";
9+import { parseWhymark } from "../src/lib/whymark/parse";
10+import type { QualityFinding, QualityReport } from "../src/lib/quality/types";
11+const dirs: string[] = [];
12+const temp = () => { const dir = mkdtempSync(join(tmpdir(), "whymark-quality-")); dirs.push(dir); return dir; };
13+afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); });
14+const finding: QualityFinding = { id: "", tool: "eslint", rule: "complexity", severity: "warning", message: "Too complex", path: "a.ts", line: 2, endLine: 3, status: "uncompared" };
15+function report(findings = [finding]): QualityReport {
16+ return { version: 1, ran: new Date().toISOString(), source: "a".repeat(64), sourceAfter: "a".repeat(64), head: "b".repeat(40), clean: true, base: "b".repeat(40), reviewHash: "c".repeat(64), provenance: "local-run", comparison: "unavailable", checks: [{ id: "eslint", command: "eslint", version: "9", status: "pass", exitCode: 0, outputHash: "d".repeat(64), outputArtifact: "output.log", detail: "" }], findings };
17+}
18+describe("quality import", () => {
19+ it("normalizes locations and rejects malformed/unsafe/oversized reports", () => {
20+ const json = JSON.stringify([{ filePath: "a.ts", messages: [{ ruleId: "complexity", severity: 1, message: "Too complex", line: 2 }] }]);
21+ expect(parseESLint(json, name => name)[0]).toMatchObject({ path: "a.ts", severity: "warning", line: 2 });
22+ expect(() => parseESLint(json, () => "../outside")).toThrow(/escapes/);
23+ expect(() => parseESLint('[{"filePath":"a.ts","messages":[{}]}]', name => name)).toThrow(/Malformed/);
24+ expect(() => parseBoundedJson("x".repeat(8 * 1024 * 1024 + 1))).toThrow(/8 MB/);
25+ expect(readQuality({ ...report(), findings: [{ ...finding, helpUrl: "javascript:alert(1)" }] })).toBeNull();
26+ expect(readQuality(report())).not.toBeNull();
27+ expect(readQuality({ ...report(), findings: [{ ...finding, suppression: null }] })).toBeNull();
28+ });
29+});
30+describe("baseline comparison", () => {
31+ it("distinguishes existing, new and resolved findings across shifts and renames", () => {
32+ const before = report([finding, { ...finding, rule: "removed" }]);
33+ const after = report([{ ...finding, path: "b.ts", line: 20, endLine: 21 }, { ...finding, rule: "new" }]);
34+ const result = compareFindings(after, before, new Map([["a.ts", "b.ts"]]));
35+ expect(result.comparison).toBe("available");
36+ expect(result.findings.map(f => f.status)).toEqual(["existing", "new", "resolved"]);
37+ });
38+ it("does not claim a clean baseline after failures or a different source revision", () => {
39+ const before = report(); before.checks[0].status = "unavailable";
40+ expect(compareFindings(report(), before).comparison).toBe("unavailable");
41+ expect(compareFindings({ ...report(), base: "different" }, report()).findings[0].status).toBe("uncompared");
42+ expect(compareFindings(report(), { ...report(), clean: false }).comparison).toBe("unavailable");
43+ expect(compareFindings(report(), null).comparison).toBe("unavailable");
44+ });
45+ it("only applies suppressions with a reason and a future expiry", () => {
46+ const findings = identifyFindings([finding]);
47+ expect(applySuppressions(findings, [{ id: findings[0].id, reason: "legacy", expires: "2000-01-01" }])[0].suppression).toBeUndefined();
48+ expect(applySuppressions(findings, [{ id: findings[0].id, reason: "legacy", expires: "2100-01-01" }])[0].suppression?.reason).toBe("legacy");
49+ });
50+});
51+describe("configured checks", () => {
52+ it("runs the installed ESLint against a real temporary project", async () => {
53+ const cwd = temp(); execFileSync("git", ["init", "-q"], { cwd });
54+ writeFileSync(join(cwd, "a.js"), "const unused = 1;\n");
55+ writeFileSync(join(cwd, "eslint.config.mjs"), 'export default [{rules: {"no-unused-vars": "error"}}];');
56+ const executable = JSON.stringify(resolve("node_modules/eslint/bin/eslint.js"));
57+ execFileSync("git", ["add", "a.js", "eslint.config.mjs"], { cwd });
58+ execFileSync("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-qm", "Baseline"], { cwd });
59+ const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim();
60+ const doc = parseWhymark(`---\nwhymark: 1\ntitle: test\nbase: HEAD@${head.slice(0, 7)}\n---\n`);
61+ const result = await scanQuality(doc, cwd, { version: 1, checks: [{ id: "eslint", format: "eslint", command: `node ${executable} . --format json`, versionCommand: `node ${executable} --version` }] });
62+ expect(result.checks[0].status).toBe("fail");
63+ expect(result.findings[0]).toMatchObject({ rule: "no-unused-vars", path: "a.js", status: "uncompared" });
64+ expect(result.source).toBe(result.sourceAfter);
65+ expect(readQuality(result)).not.toBeNull();
66+ expect(result.clean).toBe(true);
67+ expect(result.base).toBe(head);
68+ writeFileSync(join(cwd, "a.js"), "\n\nconst unused = 1;\n");
69+ const changed = await scanQuality(doc, cwd, { version: 1, checks: [{ id: "eslint", format: "eslint", command: `node ${executable} . --format json`, versionCommand: `node ${executable} --version` }] }, { baseline: result });
70+ expect(changed.comparison).toBe("available");
71+ expect(changed.findings[0].status).toBe("existing");
72+ });
73+ it("reports missing tools, enforces bounded output and cancels a running process", async () => {
74+ const cwd = temp();
75+ expect((await runTool("whymark-nonexistent-command", cwd)).unavailable).toBe(true);
76+ const controller = new AbortController();
77+ const promise = runTool('node -e "setTimeout(() => {}, 20000)"', cwd, controller.signal);
78+ setTimeout(() => controller.abort(), 30);
79+ expect((await promise).unavailable).toBe(true);
80+ expect((await runTool('node -e "process.stdout.write(\'x\'.repeat(9*1024*1024))"', cwd)).unavailable).toBe(true);
81+ });
82+ it("rejects malformed configuration and never silently runs imported commands", () => {
83+ const cwd = temp(); mkdirSync(join(cwd, "reviews"));
84+ const path = join(cwd, "whymark.config.json"); writeFileSync(path, '{"version":1,"checks":[]}');
85+ expect(() => readConfig(path)).toThrow(/Invalid/);
86+ const doc = parseWhymark('---\nwhymark: 1\ntitle: hostile\nchecks:\n - cmd: touch never-run\n status: pass\n---\n');
87+ expect(doc.meta.checks[0].cmd).toBe("touch never-run");
88+ });
89+});
intentnormalconfidence unknown

tests/skill-install.test.ts

+35 0read-only1100%
@@ -0,0 +1,35 @@
1+import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join, resolve } from "node:path";
4+import { afterEach, describe, expect, it } from "vitest";
5+import { installSkill } from "../src/lib/skill/install";
6+const dirs: string[] = [];
7+const temp = () => { const dir = mkdtempSync(join(tmpdir(), "whymark-skill-")); dirs.push(dir); return dir; };
8+afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); });
9+const packageRoot = resolve(".");
10+describe("npm skill installer", () => {
11+ it("previews, installs both agents, and is idempotent", () => {
12+ const cwd = temp();
13+ const options = { packageRoot, cwd, agents: ["codex", "claude-code"] };
14+ expect(installSkill({ ...options, dryRun: true }).every(item => item.action === "create")).toBe(true);
15+ expect(installSkill(options)).toHaveLength(4);
16+ expect(installSkill(options).every(item => item.action === "unchanged")).toBe(true);
17+ expect(readFileSync(join(cwd, ".agents/skills/whymark/SKILL.md"), "utf8")).toContain("references/whymark-v1.md");
18+ expect(readFileSync(join(cwd, ".claude/skills/whymark/references/whymark-v1.md"), "utf8")).toContain("Annotations");
19+ });
20+ it("preflights conflicts before writing another target", () => {
21+ const cwd = temp();
22+ mkdirSync(join(cwd, ".claude/skills/whymark"), { recursive: true });
23+ writeFileSync(join(cwd, ".claude/skills/whymark/SKILL.md"), "custom");
24+ expect(() => installSkill({ packageRoot, cwd, agents: ["codex", "claude-code"] })).toThrow(/overwrite/);
25+ expect(() => readFileSync(join(cwd, ".agents/skills/whymark/SKILL.md"))).toThrow();
26+ installSkill({ packageRoot, cwd, agents: ["claude-code"], force: true });
27+ });
28+ it("rejects symlink destinations even with force and scopes global installs", () => {
29+ const cwd = temp(); const home = temp(); const outside = temp();
30+ symlinkSync(outside, join(cwd, ".agents"));
31+ expect(() => installSkill({ packageRoot, cwd, force: true })).toThrow(/symlink/);
32+ const installed = installSkill({ packageRoot, cwd, home, global: true });
33+ expect(installed.every(item => item.path.startsWith(realpathSync(home)))).toBe(true);
34+ });
35+});
intentnormalconfidence unknown

whymark.config.json

+18 0read-only1100%
@@ -0,0 +1,18 @@
1+{
2+ "version": 1,
3+ "checks": [
4+ {
5+ "id": "eslint",
6+ "format": "eslint",
7+ "command": "node node_modules/eslint/bin/eslint.js . --format json --rule 'complexity: [warn, 15]' --rule 'max-depth: [warn, 4]'",
8+ "versionCommand": "node node_modules/eslint/bin/eslint.js --version"
9+ },
10+ {
11+ "id": "typecheck",
12+ "format": "check",
13+ "command": "npm run typecheck",
14+ "versionCommand": "node node_modules/typescript/bin/tsc --version"
15+ }
16+ ],
17+ "suppressions": []
18+}
intentnormalconfidence unknown