@bongos/core 1.19.889 → 1.19.890

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.889",
6
- "core_contract": "1.19.889",
7
- "source_commit": "b7871cd73a1ce5f73988d99ef63101a6a2d23050",
5
+ "core_version": "1.19.890",
6
+ "core_contract": "1.19.890",
7
+ "source_commit": "fb73c80a33c2f4e3aaf8d9ae3bb0295b6ec73e3b",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-19T21:23:27.620Z",
9
+ "built_at": "2026-09-19T21:31:12.716Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 497,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2269,
14
+ "functional_verbatim": 2271,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2792,
20
- "tree_sha256": "b7b8bc9e53808070c5d46a211481ceef0dea2c901bc79e2b0dc0871e60c362b1",
19
+ "file_count": 2794,
20
+ "tree_sha256": "8e1cc8300c4c29b876e8dc18b16384db7ade73e5948c7e009c0b69f9dadea8a7",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2607,7 +2607,7 @@
2607
2607
  {
2608
2608
  "path": "docs/module-api-changelog.md",
2609
2609
  "mode": "0000644",
2610
- "sha256": "01a90a40951ae88d4c36c5fabbaf3debc0e02bcaa9fa8a7b70e247aa5d61d236"
2610
+ "sha256": "7fb640ef029a52e521d43e2828ca0201a7e53c7a94f23fcebd58aefd6f6820fe"
2611
2611
  },
2612
2612
  {
2613
2613
  "path": "docs/modules-contract.md",
@@ -4442,12 +4442,12 @@
4442
4442
  {
4443
4443
  "path": "modules/grading/grader-rubric.json",
4444
4444
  "mode": "0000644",
4445
- "sha256": "a08fe9f320dab2d3f699700cfb9f78d1604533d930e794d556c8f9ab533cf69b"
4445
+ "sha256": "de0ca17fedb33bd54d73759e14e6813cd9f2c8c85ef2180e49c54794c7a63a3d"
4446
4446
  },
4447
4447
  {
4448
4448
  "path": "modules/grading/grader-score.js",
4449
4449
  "mode": "0000644",
4450
- "sha256": "07559f7c1f53ac8de36e1fd23965a9c29344101468542d6e636a3ce4d8860b4f"
4450
+ "sha256": "e0f665b760e2b299a7aa9098e1ab967aa330ff20f7430e0aa23d9c7c57aa0d70"
4451
4451
  },
4452
4452
  {
4453
4453
  "path": "modules/grading/grader-subagent.js",
@@ -4462,7 +4462,7 @@
4462
4462
  {
4463
4463
  "path": "modules/grading/grader-workers/extract-json.js",
4464
4464
  "mode": "0000644",
4465
- "sha256": "1be95c661d95cfc7f54b9f2776eed6c1cd5b6e2e852b24c80f6b638f4d775751"
4465
+ "sha256": "b359c904c68ae8b36ac6518c79bcebf251abcf7aaef3dae4aff4a6e2175e7914"
4466
4466
  },
4467
4467
  {
4468
4468
  "path": "modules/grading/grader-workers/fence.js",
@@ -4477,7 +4477,7 @@
4477
4477
  {
4478
4478
  "path": "modules/grading/grader-workers/index.js",
4479
4479
  "mode": "0000644",
4480
- "sha256": "c527d3dd996b4064ae14836e305de097edea2b1a3c46776d8ec7da8c2c6afe71"
4480
+ "sha256": "ec8046b24550ef81c26b8a421c684360873f9eaed0ec3a7e95b8546aa1e45d6f"
4481
4481
  },
4482
4482
  {
4483
4483
  "path": "modules/grading/grader-workers/narc.js",
@@ -4489,6 +4489,11 @@
4489
4489
  "mode": "0000644",
4490
4490
  "sha256": "1ad616532dc2c240bf94992db17b12f1334e3e74667c402d997c03c2c8df1566"
4491
4491
  },
4492
+ {
4493
+ "path": "modules/grading/grader-workers/reader.js",
4494
+ "mode": "0000644",
4495
+ "sha256": "b7fdf28a76914c708b628a20ee90278ed29ad1e4c886b4e4f0edb2a2515d4d2c"
4496
+ },
4492
4497
  {
4493
4498
  "path": "modules/grading/grader-workers/rubric.js",
4494
4499
  "mode": "0000644",
@@ -4507,7 +4512,7 @@
4507
4512
  {
4508
4513
  "path": "modules/grading/grader.js",
4509
4514
  "mode": "0000644",
4510
- "sha256": "4c664f5080513169c35369e5406f32efa64ce24259fefc47f527a94ff8001b1f"
4515
+ "sha256": "1234bb3c9938fd4cf71c39e4660d69c56a27de667ee7ff09b0f865374b6beba4"
4511
4516
  },
4512
4517
  {
4513
4518
  "path": "modules/grading/module.json",
@@ -5367,7 +5372,7 @@
5367
5372
  {
5368
5373
  "path": "modules/lifecycle/cascade.js",
5369
5374
  "mode": "0000644",
5370
- "sha256": "acc9cf557aabc3fe10efc8c7272162cf288428e8077a24f5df06648e30881337"
5375
+ "sha256": "b168a58926ee0506d3fba4f448c797b8611bc8ae0dfeca322086f22769e7ae35"
5371
5376
  },
5372
5377
  {
5373
5378
  "path": "modules/lifecycle/check-rollup.js",
@@ -5797,7 +5802,7 @@
5797
5802
  {
5798
5803
  "path": "modules/lifecycle/tests/cascade.mjs",
5799
5804
  "mode": "0000644",
5800
- "sha256": "0734ad734ac5ef05c86fa81493c12fed08ffe421781d9ceeebe413c876bf9cf1"
5805
+ "sha256": "9439accf7648ef22000c75f3e315695c806969eb5a908275d7db33e5491499f8"
5801
5806
  },
5802
5807
  {
5803
5808
  "path": "modules/lifecycle/version-autoclose.js",
@@ -7702,12 +7707,12 @@
7702
7707
  {
7703
7708
  "path": "package-lock.json",
7704
7709
  "mode": "0000644",
7705
- "sha256": "d1348f68cd79ad102a381e3d7f90b784d9df605e859269ede84ad859cc831deb"
7710
+ "sha256": "f9267b40d96aa3395f93b456da92cb8ff99f579340e1e9ac57e2264764e7b293"
7706
7711
  },
7707
7712
  {
7708
7713
  "path": "package.json",
7709
7714
  "mode": "0000644",
7710
- "sha256": "36e895c05e2aed3f97ce34ae3b3f73b0492fff7185077519068bc69dda6a8fd6"
7715
+ "sha256": "4185cf704dd2b4d4dddd72ad802fbc6861c66181b69bbc66eaafdec7e603da35"
7711
7716
  },
7712
7717
  {
7713
7718
  "path": "public-docs/index.html",
@@ -9062,7 +9067,7 @@
9062
9067
  {
9063
9068
  "path": "scripts/gds/ship-grade.js",
9064
9069
  "mode": "0000644",
9065
- "sha256": "79fcf45dd254681e7f91fe83fbb8a208b30c93eebe77df569aae505a025daa68"
9070
+ "sha256": "b1fb38635433b579fbb38d65e92b9db226cccb753866ab7a2a15227796be66cd"
9066
9071
  },
9067
9072
  {
9068
9073
  "path": "scripts/gds/ship-honesty.js",
@@ -9712,7 +9717,7 @@
9712
9717
  {
9713
9718
  "path": "src/module-api.js",
9714
9719
  "mode": "0000644",
9715
- "sha256": "541b09991f4858b3255f250ef7e52e86b0d561ec69d058e74bc218fe1a104932"
9720
+ "sha256": "7b2ddf6ecca1f92f1ae2ba7a70e0e6aeb9726ec0fa9c4a5375f075755b036796"
9716
9721
  },
9717
9722
  {
9718
9723
  "path": "src/module-loader/catalog.js",
@@ -10962,7 +10967,7 @@
10962
10967
  {
10963
10968
  "path": "tests/extract_json.mjs",
10964
10969
  "mode": "0000644",
10965
- "sha256": "8bdee65e64ba4bce81f2c8aeb3068affe78ec1bc893a8b209eece8fde5b93fa5"
10970
+ "sha256": "74f8d8852b8949dc55ac38dd6f31da85bb55aae1215a146213495db898515086"
10966
10971
  },
10967
10972
  {
10968
10973
  "path": "tests/figma_adapter.mjs",
@@ -11504,6 +11509,11 @@
11504
11509
  "mode": "0000644",
11505
11510
  "sha256": "cbe81b9176d6ffdd59ea8d6955e30aab9bf6d13bd15144fd79d126455aa0e1e3"
11506
11511
  },
11512
+ {
11513
+ "path": "tests/grader_reader_lens.mjs",
11514
+ "mode": "0000644",
11515
+ "sha256": "93e24012252c08798e3017ce9a7130ce9ece4e5e07456892b3632ab12f44ad47"
11516
+ },
11507
11517
  {
11508
11518
  "path": "tests/grader_regrade_delta.mjs",
11509
11519
  "mode": "0000644",
@@ -2252,5 +2252,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2252
2252
  landed since 1.19.887 with no explicit bump. run 35468948342. (task 1002620)
2253
2253
  1.19.889 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2254
2254
  landed since 1.19.888 with no explicit bump. run 35470239834. (task 1002620)
2255
+ 1.19.890 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2256
+ landed since 1.19.889 with no explicit bump. run 35470624284. (task 1002620)
2255
2257
  ---------------------------------------------------------------------------
2256
2258
  ```
@@ -12,16 +12,16 @@
12
12
  "assumed_builder_model": "claude-fable-5",
13
13
  "model_refresh_note": "task 1002659 (2026-08-11): pins refreshed to the Claude 5 family. Assumed builder = claude-fable-5 (the owner-top-model default per CLAUDE.md §2); default grader = claude-sonnet-5 (cross-model per ADR 0024, cost-parity with the prior sonnet-4-6 posture — opus-5 grades the sonnet/haiku builders where the grader should be the stronger model). selectGraderModel no longer throws on an unknown builder id: it warns loudly and falls back to default_model (the #117 throw stranded ships whose builder id lacked an opus/sonnet/haiku substring — exactly what claude-fable-5 is).",
14
14
  "active_panel": ["narc", "quality", "hacker", "efficiency"],
15
- "worker_model_tiers": { "quality": "claude-sonnet-5" },
16
- "worker_model_tiers_note": "task 1002660: per-worker cost tiering. Quality was 41% of panel spend with 75% nit findings — its cq/economy lens ran on the haiku tier. task 1003318: moved to claude-sonnet-5 — haiku 4.5's 200k window overflows on any diff over ~150 KB (`Prompt is too long`, deterministic; tasks 1003297 and 1003298 each lost three ~$5 panel runs to it) and a Quality worker that never starts is missing signal on a GATING lens. For fable/opus builders the panel model is already claude-sonnet-5, so the tier coincides with default_model and acts as a floor — a future default_model bump must not pin Quality back onto a smaller window; it stays a live cost lever only for haiku builders (whose panel runs on opus-5). workerModelFor refuses an override that lands on the builder's own model family (the ADR 0024 cross-model guarantee outranks the cost lever); gate semantics unchanged — Quality's verdict still gates identically.",
15
+ "worker_model_tiers": { "quality": "claude-sonnet-5", "reader": "claude-sonnet-5" },
16
+ "worker_model_tiers_note": "task 1002660: per-worker cost tiering. Quality was 41% of panel spend with 75% nit findings — its cq/economy lens ran on the haiku tier. task 1003318: moved to claude-sonnet-5 — haiku 4.5's 200k window overflows on any diff over ~150 KB (`Prompt is too long`, deterministic; tasks 1003297 and 1003298 each lost three ~$5 panel runs to it) and a Quality worker that never starts is missing signal on a GATING lens. For fable/opus builders the panel model is already claude-sonnet-5, so the tier coincides with default_model and acts as a floor — a future default_model bump must not pin Quality back onto a smaller window; it stays a live cost lever only for haiku builders (whose panel runs on opus-5). workerModelFor refuses an override that lands on the builder's own model family (the ADR 0024 cross-model guarantee outranks the cost lever); gate semantics unchanged — Quality's verdict still gates identically. task 1003977: the READER is pinned to claude-sonnet-5 for the same task-1003318 reason — it is Read-heavy by construction (the voice guide, then the render paths that decide whether a string is user-facing), and a small window fails deterministically with PROMPT_TOO_LONG. For a NON-GATING worker that means silently reading no copy at all on exactly the biggest copy diffs, with nothing blocking to reveal it.",
17
17
  "fallthrough_confidence": 0.3,
18
18
  "low_confidence_threshold": 0.4,
19
19
  "rationale": "GDS-V3 task #410 (MAS Phase 2.0), 2026-05-29. Supersedes v3 (single-prompt 3-shot generalist). Phase 1 calibration showed minimal between-shot disagreement on the same prompt — the strict gate fired correctly but the diversity signal was weak. v4 trades sampling-variance for lens-specialization: replace 3 identical-prompt shots with N workers (each a distinct prompt focused on one axis). Phase 2.0 ships 2 of the 3 planned workers (narc for completion, quality for cq + advisory economy) — keeps the gate at the same strictness while the framework calibrates. Phase 2.1 adds hacker (security; wraps /builder-redteam as a CI gate). Phase 2.2 adds efficiency_monger.",
20
20
  "worker_contract": {
21
- "kind": "string — worker registry key (narc | quality | hacker | efficiency)",
21
+ "kind": "string — worker registry key (narc | quality | hacker | efficiency | reader)",
22
22
  "name": "string — display name",
23
23
  "specialty": "string — one-line description of what this worker judges",
24
- "verdict": "'pass' | 'concerns' | 'fail' — per-worker verdict, sub-classification of the panel gate",
24
+ "verdict": "'pass' | 'concerns' | 'fail' — per-worker verdict, sub-classification of the panel gate; plus 'advisory', the ONLY verdict a NON-GATING worker emits (task 1003977 — see active_workers.reader and panel_gate.non_gating). Mirrored by WORKER_VERDICTS in grader-score.js, which hasPanelEvidence checks with an `every`.",
25
25
  "scores": "{ functional_fidelity?, code_quality?, economy? } — only the axes this worker reports",
26
26
  "issues": "[{ severity, file, summary }] — concrete problems found",
27
27
  "notes_md": "short markdown summary",
@@ -31,12 +31,22 @@
31
31
  "error": "null | 'parse_error' | 'schema_error' | 'SUBAGENT_*' — set when the subagent comms failed; verdict will be 'fail'"
32
32
  },
33
33
  "panel_gate": {
34
- "fail": "ANY worker.verdict='fail' OR worker.error != null → overall passed=false, no advance to confirmed.",
35
- "concerns": "ANY worker.verdict='concerns' (and no fails) → overall passed=false, forwarded confidence = fallthrough_confidence (manual-confirm available).",
36
- "pass": "ALL workers.verdict='pass' → overall passed=true, advance to confirmed."
34
+ "fail": "ANY GATING worker.verdict='fail' OR worker.error != null → overall passed=false, no advance to confirmed.",
35
+ "concerns": "ANY GATING worker.verdict='concerns' (and no fails) → overall passed=false, forwarded confidence = fallthrough_confidence (manual-confirm available).",
36
+ "pass": "ALL GATING workers.verdict='pass' → overall passed=true, advance to confirmed.",
37
+ "non_gating": "task 1003977: a worker in NON_GATING_WORKERS (today: reader) is partitioned out of workerResults BEFORE any gate arithmetic — the pass/concerns/fail counts, the missing-signal and total-outage detection, the forwarded-confidence median and every verdict branch read the gating workers only. Its scores and issues still land in rubric_scores and signals.workers. Consequence worth stating out loud: a HEALTHY non-gating worker cannot mask a total grader outage by making 'every gating worker is missing signal' false — that is the task-1003574 regression class, where an outage presented as FAIL 0/10 and routed the builder to a re-grade that could not clear it."
37
38
  },
38
39
  "severity_gate_policy": "Task 1002663: style findings (comment style/length/placement, naming conventions, doc placement, test-name conventions) are ADVISORY — severity nit, recorded in issues[] for the audit trail, never a verdict driver. Enforced deterministically in grader-workers/worker-verdict.js applySeverityGatePolicy for narc/quality/efficiency: a worker 'fail' whose only basis is a sub-threshold score with NO major/blocker finding is an inconsistent self-report and downgrades to 'concerns' (manual-confirm pause) — a worker whose findings are all nit/minor can never verdict fail. The HACKER is deliberately EXEMPT: the security lens stays fail-closed on an unbacked sub-threshold score (a real finding mislabeled minor must still hard-fail). Untouched everywhere: blocker-driven fails, non-score fail triggers (Narc missing-AC), error short-circuits. Every worker prompt carries the matching STYLE_ADVISORY paragraph (worker-prompt.js).",
39
40
  "active_workers": {
41
+ "reader": {
42
+ "specialty": "user-facing copy: truth, clarity, voice, judged against docs/voice.md",
43
+ "scores_reported": ["copy_score"],
44
+ "gating": false,
45
+ "verdict_rules": "NON-GATING (task 1003977, ADR 0298). Emits the single verdict 'advisory' on every path INCLUDING its own errors — computeVerdict has no branch returning pass/concerns/fail, so 'cannot block' is a property of the function, not a promise kept by callers. aggregatePanel additionally partitions it out of the gate (NON_GATING_WORKERS in grader-workers/index.js). Belt and braces: a filter is one refactor from being forgotten, a verdict is not. Stronger than the advisory treatment narc receives, which only downgrades a fail to manual-confirm — the reader cannot reach 'concerns' either. A 'blocker' severity is FLOORED to 'major' rather than dropped: the observation survives, the veto does not. Deliberately NO blocker escape hatch of the kind narc/hacker carry (grader.js isAdvisory, '... && !hasBlocker'), because that hatch is the exact hole the owner's term closes. An errored reader records NO copy_score rather than 0 — a 0 is a real reading, an outage is missing signal (the #943 distinction, per worker).",
46
+ "scope": "copy-bearing diffs ONLY. The caller (scripts/gds/ship-grade.js) asks hasCopyOrVisualWork(task) from modules/lifecycle/cascade.js and passes the answer to WORKERS.panelFor({ copyBearing }); the grading module may not require a sibling module (ADR 0083, CI-enforced), and a second predicate inside grading would let the at-ship lens and the after-the-fact artist review disagree about what they cover. Where false the panel is byte-identical to DEFAULT_PANEL and costs what it costs today, and the skip is recorded as signals.reader_skipped='not_copy_bearing' — never a silent absence, because a missing copy score must not read as a passing one.",
47
+ "standard": "docs/voice.md (task 1003976), cited by rule id. Hard rules V1-V5 are checkable and may be ASSERTED as breaks; judgement rules V6-V11 may only be FLAGGED with the line quoted. A finding that cites no rule id is not a finding — the guide's own rule, carried into the prompt, and what keeps an advisory lens from recording taste as fact.",
48
+ "prompt_lives_in": "modules/grading/grader-workers/reader.js"
49
+ },
40
50
  "narc": {
41
51
  "specialty": "semantic completion (description vs diff, line-level)",
42
52
  "scores_reported": ["functional_fidelity"],
@@ -55,7 +55,13 @@ function isSubstantiveIssue(it) {
55
55
  // grade-time input (the ADR's documented honor-system residual for Metic+);
56
56
  // its job is separating "the panel ran and unanimously found nothing" (the
57
57
  // guard's confirmed false-positive class) from "no evidence at all".
58
- const WORKER_VERDICTS = new Set(['pass', 'concerns', 'fail']);
58
+ // 'advisory' (task 1003977) is the Reader's only verdict — a non-gating lens
59
+ // whose row must still COUNT AS EVIDENCE that the panel ran. Omit it here and
60
+ // hasPanelEvidence returns false for every copy-bearing ship, because the check
61
+ // below is an `every`: one unrecognised verdict makes a fully-healthy five-worker
62
+ // panel look like no evidence at all, and the implausible-pass guard then fires
63
+ // on exactly the ships that carry the most lenses.
64
+ const WORKER_VERDICTS = new Set(['pass', 'concerns', 'fail', 'advisory']);
59
65
  function hasPanelEvidence(signals) {
60
66
  const ws = Array.isArray(signals?.workers) ? signals.workers : null;
61
67
  if (!ws || ws.length < 3) return false;
@@ -17,7 +17,13 @@
17
17
 
18
18
  // Score keys that mark a parsed object as the real grade (vs an incidental
19
19
  // prose object). Module-level so extractJson doesn't re-allocate per call.
20
- const GRADE_SHAPE_KEYS = ['functional_fidelity', 'code_quality', 'security_score', 'efficiency_score'];
20
+ // task 1003977: 'copy_score' joins the list when the Reader joins the panel.
21
+ // reader.js re-exports THIS extractJson, so without its key the disambiguation
22
+ // above silently does not apply to it — an ambiguous multi-object response would
23
+ // fall back to candidates[0] with no score-based preference. It fails safe (a
24
+ // non-gating worker with no signal blocks nothing), but failing safe is not the
25
+ // same as getting the protection every other worker gets.
26
+ const GRADE_SHAPE_KEYS = ['functional_fidelity', 'code_quality', 'security_score', 'efficiency_score', 'copy_score'];
21
27
 
22
28
  function extractJson(raw) {
23
29
  if (typeof raw !== 'string') return null;
@@ -18,18 +18,52 @@ const narc = require('./narc');
18
18
  const quality = require('./quality');
19
19
  const hacker = require('./hacker');
20
20
  const efficiency = require('./efficiency');
21
+ const reader = require('./reader');
21
22
 
22
23
  const REGISTRY = {
23
24
  [narc.KIND]: narc,
24
25
  [quality.KIND]: quality,
25
26
  [hacker.KIND]: hacker,
26
27
  [efficiency.KIND]: efficiency,
28
+ [reader.KIND]: reader,
27
29
  };
28
30
 
29
31
  // The active panel for the ship-time gate. Order is presentation-only —
30
32
  // workers run in parallel inside gradeSubagent.
31
33
  const DEFAULT_PANEL = [narc.KIND, quality.KIND, hacker.KIND, efficiency.KIND];
32
34
 
35
+ // Workers that are NEVER part of the gate — not a hard fail, not a downgrade to
36
+ // concerns, not a missing-signal outage. Distinct from grader.js's
37
+ // ADVISORY_WORKERS ('narc'), which only softens a fail into manual-confirm.
38
+ //
39
+ // The Reader is here because the owner's term for it (2026-09-17, ADR 0298) is
40
+ // absolute: ADR 0241 put the artist's judgement on the DEPLOY and off the ship
41
+ // path, and a copy lens that could hold a ship puts back what ADR 0162 retired.
42
+ // Deliberately NO blocker escape hatch of the kind narc/hacker carry — see the
43
+ // header of reader.js for why that hatch would be the hole the term closes.
44
+ //
45
+ // This set is belt; reader.js's computeVerdict, which cannot return anything but
46
+ // 'advisory', is braces. Either alone would hold; both, because a filter is one
47
+ // refactor from being forgotten and a verdict is not.
48
+ const NON_GATING_WORKERS = new Set([reader.KIND]);
49
+ function isNonGatingKind(kind) { return NON_GATING_WORKERS.has(kind); }
50
+
51
+ // The panel for one ship. `copyBearing` is the CALLER's answer, never computed
52
+ // here: the one definition is hasCopyOrVisualWork() in modules/lifecycle/
53
+ // cascade.js, and a module may require only src/module-api.js (ADR 0083), so
54
+ // grading cannot reach it. Writing a second predicate here is exactly what the
55
+ // task forbids — the at-ship lens and the after-the-fact artist review must
56
+ // never disagree about what they cover, and widening that set must widen both
57
+ // at once. So the caller (scripts/gds/ship-grade.js, which is not a module and
58
+ // may require both) asks lifecycle and passes the answer down.
59
+ //
60
+ // Where it is false the panel is BYTE-IDENTICAL to today's and costs what it
61
+ // costs today. The skip is recorded by the caller as a visible signal, never as
62
+ // a silent absence — a missing copy score must not read as a passing one.
63
+ function panelFor({ copyBearing = false } = {}) {
64
+ return copyBearing ? [...DEFAULT_PANEL, reader.KIND] : [...DEFAULT_PANEL];
65
+ }
66
+
33
67
  // #465: transient subprocess/parse errors are infra noise, not a quality
34
68
  // signal — auto-retry the worker ONCE on these before letting the error stand.
35
69
  // Deliberately EXCLUDES:
@@ -79,6 +113,11 @@ const WORKER_MAX_TIMEOUT_MS = {
79
113
  quality: 360_000, // Read-heavy: reads changed files for cleanliness review
80
114
  hacker: 360_000, // Read-heavy on a security diff: exploit-surface scan of every changed file
81
115
  efficiency: 360_000, // Read-heavy on a large diff: traces perf across every changed file
116
+ // Read-heavy by construction: the Reader's first Read is the voice guide, and
117
+ // the rest confirm that a string really is user-facing. Same heavy tier as the
118
+ // rest — worst-case panel wall time is max(), and the four already sit at 360s,
119
+ // so adding a fifth at the same budget does not move it (task 1003977).
120
+ reader: 360_000,
82
121
  };
83
122
 
84
123
  // task 1003313: the diagnostic a dark panel used to throw away.
@@ -244,6 +283,9 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
244
283
  module.exports = {
245
284
  REGISTRY,
246
285
  DEFAULT_PANEL,
286
+ NON_GATING_WORKERS,
287
+ isNonGatingKind,
288
+ panelFor,
247
289
  resolvePanel,
248
290
  runWorker,
249
291
  timeoutForWorker, // public surface; WORKER_MAX_TIMEOUT_MS stays module-internal
@@ -255,4 +297,5 @@ module.exports = {
255
297
  quality,
256
298
  hacker,
257
299
  efficiency,
300
+ reader,
258
301
  };
@@ -0,0 +1,218 @@
1
+ // The Reader — the advisory copy lens (task 1003977, ADR 0298).
2
+ //
3
+ // Specialty: the SENTENCES. Is a line a person reads true, clear, in the
4
+ // project's voice, and worth their attention? The other four workers read
5
+ // completion (Narc), code quality (Quality), security (Hacker) and runtime cost
6
+ // (Efficiency); not one of them reads a sentence. Task 1003937 established that
7
+ // across four consecutive artist reviews, all of which found the same wall and
8
+ // all of whose graded ships deservedly passed on the axes that WERE measured.
9
+ //
10
+ // ADVISORY, FULL STOP — and that is structural here, not a filter elsewhere.
11
+ // This worker's computeVerdict cannot return 'fail' or 'concerns'; it returns
12
+ // the single verdict 'advisory' on every path, including its own errors. The
13
+ // panel then excludes it from the gate (NON_GATING_WORKERS in index.js, and the
14
+ // partition in grader.js aggregatePanel). Two independent mechanisms, because
15
+ // the owner's term (2026-09-17) is absolute: ADR 0241 put the artist's judgement
16
+ // on the DEPLOY and off the ship path, after ADR 0162 retired review gates for
17
+ // stranding graded-PASS work. A reader that could fail a ship puts that back.
18
+ //
19
+ // NOTE THIS IS STRONGER THAN ADVISORY_WORKERS. The Narc is "advisory" in the
20
+ // sense that a Narc-only fail DOWNGRADES to concerns (manual-confirm). The
21
+ // Reader cannot reach concerns either. There is deliberately NO blocker escape
22
+ // hatch of the kind narc/hacker carry ("advisory ONLY if it carries no
23
+ // blocker"): a copy lens finding something that genuinely blocks a ship is a
24
+ // finding in another lens's territory, and the escape hatch would be the exact
25
+ // hole the owner's term closes.
26
+ //
27
+ // THE STANDARD IS WRITTEN DOWN. docs/voice.md (task 1003976) is the rubric, and
28
+ // its own rule is that a finding citing no rule id is not a finding. The guide
29
+ // splits HARD rules (V1-V5, checkable — a lens may assert a break) from
30
+ // JUDGEMENT rules (V6-V11, recognisable but not provable — a lens may only FLAG
31
+ // a candidate and must quote the line). That split is reproduced in the prompt
32
+ // below, because it is what keeps an advisory lens from recording taste as fact.
33
+
34
+ const { buildWorkerPrompt, LLM_PROJECT } = require('./worker-prompt');
35
+ const { parseCommon } = require('./worker-verdict');
36
+ const { extractJson } = require('./extract-json');
37
+
38
+ const KIND = 'reader';
39
+ const NAME = 'Reader';
40
+ const SPECIALTY = 'user-facing copy: truth, clarity, voice (docs/voice.md), against the written guide';
41
+
42
+ // The ONE verdict this worker emits. Not 'pass': a pass is a claim the gate may
43
+ // read, and an errored Reader claiming 'pass' would be a false one. Registered
44
+ // in grader-score.js WORKER_VERDICTS so hasPanelEvidence still recognises a
45
+ // panel that carries a Reader row.
46
+ const ADVISORY_VERDICT = 'advisory';
47
+
48
+ const PROMPT_HEADER = `You are the **Reader** — the copy lens for the ${LLM_PROJECT} project. You read the SENTENCES a person will see.
49
+
50
+ You are ADVISORY. Nothing you report can block this ship, and you should not write as though it could. Your output is coaching for the artist who reviews this work afterwards, and a record of where the grade was otherwise blind.
51
+
52
+ Your ONLY job is user-facing copy. You do NOT score whether the description was implemented (Narc), whether the code is well-written (Quality), whether it is secure (Hacker), or what it costs to run (Efficiency). If you notice something in those territories, say so in \`notes_md\` and move on — do not file it as an issue.
53
+
54
+ ## The standard is written down, and you must cite it
55
+
56
+ \`docs/voice.md\` is the voice guide. **Read it first** — it is the rubric for this grade, and its rules carry ids (V1, V2, …).
57
+
58
+ **A finding that cites no rule id is not a finding.** If a line is bad and no rule in the guide reaches it, say that in \`notes_md\` as a gap in the guide. Do not invent a rule at grade time. This is the guide's own instruction and it is what separates a lens from a second model's taste.
59
+
60
+ The guide splits its rules in two, and the split governs what you may say:
61
+
62
+ - **Hard rules (V1-V5)** can be checked. A line either breaks one or it does not. You MAY assert a break.
63
+ - **Judgement rules (V6-V11)** name a failure a person can recognise but a checker cannot prove. You may **flag a candidate** and must quote the line and the rule id. You may NEVER assert that the line is wrong. The artist decides.
64
+
65
+ Set \`kind\` on every issue to \`"break"\` (hard rule, asserted) or \`"flag"\` (judgement rule, offered). A judgement-rule issue filed as a break is the single most damaging thing you can do here, because it records taste as fact.
66
+
67
+ ## What counts as user-facing copy
68
+
69
+ What the guide governs: every string a person reads on the builders' hall, the status dashboard and the landing surface; server-side route failures and validation text; outbound broadcast text.
70
+
71
+ What it does NOT govern, and you must not score: repository documentation, ADRs, session logs, code comments, commit messages, skill prose. Those are written for builders and agents in ordinary technical prose. A diff that changes ONLY those carries no copy for you to read — say so and score 10.
72
+
73
+ ## How you work
74
+
75
+ 1. Read \`docs/voice.md\`.
76
+ 2. Walk the diff for CHANGED or ADDED user-facing strings. Quote each line you judge.
77
+ 3. For each, name the rule id it breaks or that it is a candidate against.
78
+ 4. Score \`copy_score\` (0-10):
79
+ - 10 = no user-facing copy changed, or every changed line is clean against the guide.
80
+ - 8 = clean; one or two judgement-rule flags worth the artist's eye.
81
+ - 6 = at least one hard-rule break in a line a person will read.
82
+ - 4 = several hard-rule breaks, or one in a high-traffic surface.
83
+ - 2 = the copy actively misleads (a word that means something else here, a claim the code does not support).
84
+ - 0 = the copy is wrong in a way that would embarrass the project in front of a user.
85
+
86
+ The score is a RECORD, not a gate. Nothing happens to the ship at any value.
87
+
88
+ 5. Severities: \`major\` for a hard-rule break on a line a person reads; \`minor\` for a hard-rule break somewhere quiet; \`nit\` for a judgement-rule flag. Never \`blocker\` — you cannot block, and a blocker severity from this lens is a category error.
89
+
90
+ ## Read budget
91
+
92
+ **5 Read calls.** Spend the first on \`docs/voice.md\`. Spend the rest confirming that a string you are judging really is user-facing — the surrounding render path tells you, and a string in a code comment is not copy.
93
+
94
+ ## Sanity checks
95
+
96
+ - A diff with no user-facing string changes scores 10 with no issues. That is a normal, common outcome. Do not manufacture findings to look useful.
97
+ - Every issue quotes the line and names a rule id.
98
+ - No issue carries severity \`blocker\`.
99
+ - Do not rewrite the copy for them unless the fix is obvious and one line; the artist owns the remake.
100
+
101
+ ## Return ONLY JSON
102
+
103
+ No prose, no markdown fences:
104
+
105
+ \`\`\`
106
+ {
107
+ "copy_score": <number 0-10>,
108
+ "confidence": <number 0-1>,
109
+ "reads_used": <integer 0-5>,
110
+ "strings_reviewed": [
111
+ { "where": "<file:line>", "line": "<the string, quoted>", "verdict": "ok"|"break"|"flag", "rule": "<V-id or null>" }
112
+ ],
113
+ "issues": [
114
+ { "severity": "major"|"minor"|"nit", "kind": "break"|"flag", "rule": "<V-id>", "file": "<path>", "summary": "<one line, quotes the copy and names the rule>" }
115
+ ],
116
+ "notes_md": "<short markdown — which surfaces carried copy, what you could not judge, and any gap in the guide you hit>"
117
+ }
118
+ \`\`\`
119
+
120
+ You have a Read tool with a hard budget of 5 calls. Do not run tests, do not edit files, do not call the network.`;
121
+
122
+ function buildPrompt({ task, valueSummary, diff, changedFiles, diffStats, priorRound, contextBundle }) {
123
+ return buildWorkerPrompt({
124
+ priorRound,
125
+ contextBundle,
126
+ header: PROMPT_HEADER,
127
+ task: task || {},
128
+ valueSummary,
129
+ diff,
130
+ changedFiles,
131
+ taskHeader: '## Task (context — you judge the copy, not the completion)',
132
+ descLabel: '**Description:**',
133
+ emptyFilesText: '(no files changed)',
134
+ readFilesHint: 'You can Read any of these files to see their post-merge state (5-call budget — spend the first on docs/voice.md).',
135
+ finalInstruction: 'Now read the copy. Cite a rule id on every finding, or do not file it. Output ONLY the JSON object.',
136
+ });
137
+ }
138
+
139
+ // The Reader's verdict is 'advisory' on EVERY path — a clean read, a bad read,
140
+ // a crashed subagent, a timeout. There is no branch that returns anything else,
141
+ // which is what makes "it cannot block" a property of this function rather than
142
+ // a promise kept by its callers.
143
+ //
144
+ // An error still travels: `error` is set, the notes say so, and copy_score is
145
+ // null rather than 0. A 0 would be a real score meaning "the copy is wrong in a
146
+ // way that would embarrass the project", and an outage is not that. The same
147
+ // distinction #943 draws for the panel — missing signal is not a bad signal.
148
+ function computeVerdict(parsed, meta = {}) {
149
+ const advisory = (extra) => ({
150
+ verdict: ADVISORY_VERDICT,
151
+ scores: {},
152
+ issues: [],
153
+ confidence: null,
154
+ error: null,
155
+ ...extra,
156
+ });
157
+
158
+ if (meta.subprocess_error) {
159
+ return advisory({
160
+ notes_md: `Reader — ADVISORY, no signal: subagent error (${meta.subprocess_error}). The copy was not read; this does not affect the ship.`,
161
+ error: meta.subprocess_error,
162
+ });
163
+ }
164
+ if (!parsed || typeof parsed !== 'object') {
165
+ return advisory({
166
+ notes_md: 'Reader — ADVISORY, no signal: could not parse subagent output as JSON. The copy was not read; this does not affect the ship.',
167
+ error: 'parse_error',
168
+ });
169
+ }
170
+
171
+ const { conf, issues: rawIssues, notesFromSubagent } = parseCommon(parsed);
172
+ const score = Number(parsed.copy_score);
173
+ const reviewed = Array.isArray(parsed.strings_reviewed) ? parsed.strings_reviewed : [];
174
+
175
+ // A blocker from this lens is a category error, not a blocking finding. It is
176
+ // FLOORED to major rather than dropped: the observation may be real and the
177
+ // artist should see it; what it may not be is a severity the gate reads as
178
+ // blocking. Dropping it would hide a finding; honouring it would hand this
179
+ // worker the veto the owner's term denies it.
180
+ const issues = rawIssues.map((i) => (
181
+ i && i.severity === 'blocker'
182
+ ? { ...i, severity: 'major', severity_floored_from: 'blocker' }
183
+ : i
184
+ ));
185
+
186
+ if (!Number.isFinite(score) || score < 0 || score > 10) {
187
+ return advisory({
188
+ issues,
189
+ notes_md: `Reader — ADVISORY, no score: copy_score was non-numeric or out of range (got ${parsed.copy_score}). Findings below still stand.`,
190
+ error: 'schema_error',
191
+ confidence: conf,
192
+ });
193
+ }
194
+
195
+ const s = Math.round(score * 10) / 10;
196
+ const breaks = issues.filter((i) => i.kind === 'break').length;
197
+ const flags = issues.filter((i) => i.kind === 'flag').length;
198
+ const prefix = `Reader — ADVISORY (copy=${s}/10, ${breaks} rule break${breaks === 1 ? '' : 's'}, ${flags} flag${flags === 1 ? '' : 's'}, ${reviewed.length} string${reviewed.length === 1 ? '' : 's'} reviewed, conf=${conf ?? '?'})`;
199
+
200
+ return advisory({
201
+ scores: { copy_score: s },
202
+ issues,
203
+ strings_reviewed: reviewed,
204
+ notes_md: `${prefix}\n${notesFromSubagent.trim()}`.trim(),
205
+ confidence: conf,
206
+ });
207
+ }
208
+
209
+ module.exports = {
210
+ KIND,
211
+ NAME,
212
+ SPECIALTY,
213
+ ADVISORY_VERDICT,
214
+ PROMPT_HEADER,
215
+ buildPrompt,
216
+ extractJson,
217
+ computeVerdict,
218
+ };
@@ -110,7 +110,27 @@ function describeOutage(gating) {
110
110
  }
111
111
 
112
112
  function aggregatePanel(workerResults, meta = {}) {
113
- const N = workerResults.length;
113
+ // task 1003977 — THE NON-GATING PARTITION, before any gate arithmetic.
114
+ //
115
+ // A non-gating worker (today: the Reader, the advisory copy lens) contributes
116
+ // its scores and issues to the RECORD and nothing at all to the GATE. It is
117
+ // removed here, once, rather than special-cased in each of the counts below —
118
+ // pass/concerns/fail counts, the error and outage detection, the confidence
119
+ // median and every branch of the verdict all read `gateResults`.
120
+ //
121
+ // Removing it from the counts is why a Reader outage cannot reach #943's
122
+ // "every gating worker is missing signal" branch, and why a Reader cannot
123
+ // nudge the confidence median that drives the server-side gate. `N` stays the
124
+ // GATE's denominator so the verdict strings keep meaning what they said
125
+ // before ("2/4 gating worker(s) failed" counts the four that can fail).
126
+ //
127
+ // ADVISORY_WORKERS below is a DIFFERENT, weaker thing: narc is advisory in
128
+ // that a narc-only fail downgrades to manual-confirm. A non-gating worker
129
+ // never reaches even that.
130
+ const isNonGating = (w) => w && workers.isNonGatingKind(w.kind);
131
+ const nonGatingResults = workerResults.filter(isNonGating);
132
+ const gateResults = workerResults.filter((w) => !isNonGating(w));
133
+ const N = gateResults.length;
114
134
 
115
135
  // Score axes — median across workers that reported each. Reuses the
116
136
  // module-level median() helper (rounded to one decimal).
@@ -124,6 +144,12 @@ function aggregatePanel(workerResults, meta = {}) {
124
144
  // Phase 2.2 (#421): Efficiency Monger reports efficiency_score. Same
125
145
  // additive treatment as security_score.
126
146
  const efficiencyScore = median(workerResults.map((w) => w.scores?.efficiency_score));
147
+ // task 1003977: the Reader reports copy_score. Recorded like security_score
148
+ // and efficiency_score before it — additive, and read from the FULL result
149
+ // list rather than gateResults, because the whole point is that a non-gating
150
+ // worker's SIGNAL lands in the record even though its verdict never reaches
151
+ // the gate. Null whenever no Reader ran, which is every non-copy-bearing ship.
152
+ const copyScore = median(workerResults.map((w) => w.scores?.copy_score));
127
153
 
128
154
  const economyPenalty =
129
155
  economy != null ? Math.max(0, (THRESHOLD - economy) / 2) : 0;
@@ -131,8 +157,8 @@ function aggregatePanel(workerResults, meta = {}) {
131
157
  const blended = ff != null && cq != null ? r1((ff + cq) / 2) : 0;
132
158
 
133
159
  // Verdict counts.
134
- const passCount = workerResults.filter((w) => w.verdict === 'pass').length;
135
- const concernsCount = workerResults.filter((w) => w.verdict === 'concerns').length;
160
+ const passCount = gateResults.filter((w) => w.verdict === 'pass').length;
161
+ const concernsCount = gateResults.filter((w) => w.verdict === 'concerns').length;
136
162
  // A crashed worker sets verdict='fail' as a DEFAULT in its computeVerdict()
137
163
  // subprocess_error / parse_error / schema_error early returns (see each
138
164
  // worker module) — that is MISSING SIGNAL, not a substantive finding, and
@@ -149,12 +175,12 @@ function aggregatePanel(workerResults, meta = {}) {
149
175
  const hasSubstantiveIssue = (w) =>
150
176
  (w.issues || []).some((i) => i.severity === 'blocker' || i.severity === 'major');
151
177
  const isMissingSignal = (w) => !!w.error && !hasSubstantiveIssue(w);
152
- const failCount = workerResults.filter((w) => w.verdict === 'fail' && !isMissingSignal(w)).length;
178
+ const failCount = gateResults.filter((w) => w.verdict === 'fail' && !isMissingSignal(w)).length;
153
179
  // NOTE (#473/#589): signals.error_count counts only isMissingSignal workers — i.e. a worker that
154
180
  // errored AND surfaced no blocking issue. A worker that errored but still raised a blocker/major
155
181
  // is a real finding, so it counts in fail_count (above), NOT here. This is a deliberate semantic
156
182
  // change from the old "all w.error" meaning; keep error_count = missing-signal-only.
157
- const errorCount = workerResults.filter((w) => isMissingSignal(w)).length;
183
+ const errorCount = gateResults.filter((w) => isMissingSignal(w)).length;
158
184
 
159
185
  // V3.R85 (#481): the Narc (functional_fidelity) is ADVISORY in the gate —
160
186
  // Lars's call 2026-05-30 after #303, where over-literal acceptance-criteria
@@ -188,8 +214,8 @@ function aggregatePanel(workerResults, meta = {}) {
188
214
  // taken out by a grader outage, so it is not evidence either way about whether the
189
215
  // panel ran. Task 1003574.
190
216
  const isDeterministic = (w) => w && w.model === 'deterministic';
191
- const nonAdvisory = workerResults.filter((w) => !isAdvisory(w));
192
- const gating = nonAdvisory.length ? nonAdvisory : workerResults;
217
+ const nonAdvisory = gateResults.filter((w) => !isAdvisory(w));
218
+ const gating = nonAdvisory.length ? nonAdvisory : gateResults;
193
219
  const gatingFail = gating.filter((w) => w.verdict === 'fail' && !isMissingSignal(w)).length;
194
220
  const gatingError = gating.filter((w) => isMissingSignal(w)).length;
195
221
  const gatingConcerns = gating.filter((w) => w.verdict === 'concerns').length;
@@ -228,15 +254,15 @@ function aggregatePanel(workerResults, meta = {}) {
228
254
  const gatingErrorAll = subagentGating.length > 0 && subagentGating.every((w) => isMissingSignal(w));
229
255
  // Advisory-worker outcomes — never a hard block. Uses isAdvisory so a Narc
230
256
  // that DID raise a blocker is treated as gating (in `nonAdvisory`), not here.
231
- const advisoryFail = workerResults.some((w) => isAdvisory(w) && w.verdict === 'fail' && !isMissingSignal(w));
232
- const advisoryConcerns = workerResults.some((w) => isAdvisory(w) && w.verdict === 'concerns');
257
+ const advisoryFail = gateResults.some((w) => isAdvisory(w) && w.verdict === 'fail' && !isMissingSignal(w));
258
+ const advisoryConcerns = gateResults.some((w) => isAdvisory(w) && w.verdict === 'concerns');
233
259
 
234
260
  // Confidence — median for pass, fallthrough for concerns, 0 for fail.
235
261
  // (Drives the server-side gate; see file header.) Computed UNROUNDED —
236
262
  // confidence sits near the 0.4 gate cap so the module-level median()
237
263
  // helper (which rounds via r1()) would risk nudging a borderline value
238
264
  // across the boundary. Inlined for that one-decimal-precision reason.
239
- const confVals = workerResults
265
+ const confVals = gateResults
240
266
  .map((w) => w.confidence)
241
267
  .filter((n) => Number.isFinite(n))
242
268
  .slice()
@@ -360,7 +386,12 @@ function aggregatePanel(workerResults, meta = {}) {
360
386
 
361
387
  // Build display notes.
362
388
  const lines = [];
363
- lines.push(`Subagent grader ${VERSION} (panel of ${N}: ${workerResults.map((w) => w.kind).join(', ')}) — ${verdict}`);
389
+ // N counts the GATING workers; a non-gating one is named separately so the
390
+ // line cannot read as an off-by-one ("panel of 4: …five kinds…").
391
+ const advisoryKinds = nonGatingResults.map((w) => w.kind);
392
+ lines.push(`Subagent grader ${VERSION} (panel of ${N}: ${gateResults.map((w) => w.kind).join(', ')}`
393
+ + (advisoryKinds.length ? ` · advisory, non-gating: ${advisoryKinds.join(', ')}` : '')
394
+ + `) — ${verdict}`);
364
395
  if (ff != null) lines.push(` functional_fidelity (Narc): ${ff}/10`);
365
396
  if (cq != null) lines.push(` code_quality (Quality): ${cq}/10`);
366
397
  if (economy != null) lines.push(` economy (Quality, adv.): ${economy}/10`);
@@ -421,6 +452,7 @@ function aggregatePanel(workerResults, meta = {}) {
421
452
  code_quality_effective: cqEffective,
422
453
  security_score: security,
423
454
  efficiency_score: efficiencyScore,
455
+ copy_score: copyScore,
424
456
  },
425
457
  signals: {
426
458
  grader_kind: 'subagent-panel',
@@ -470,6 +502,10 @@ function graderUnavailableResult({ reason, note, meta = {} } = {}) {
470
502
  code_quality_effective: null,
471
503
  security_score: null,
472
504
  efficiency_score: null,
505
+ // task 1003977: nulled with the rest so an outage grade's rubric_scores
506
+ // has the SAME SHAPE as a normal one. A key that is absent here and
507
+ // present there is a difference a consumer has to know about.
508
+ copy_score: null,
473
509
  },
474
510
  signals: {
475
511
  grader_kind: 'subagent-panel',
@@ -135,23 +135,33 @@ function hasCopyOrVisualWork(task) {
135
135
  // STEP 2 remakes the copy/visuals with both routes explicitly offered — R04's
136
136
  // brief, verbatim in intent.
137
137
  //
138
- // STEP 1 used to say "coach the grader", and for as long as that was the wording
139
- // it asked for something no artist could do (task 1003937, ADR 0298). The panel's
140
- // four lenses — narc, quality, hacker, efficiency — read completion, code quality,
141
- // security and runtime cost; not one of them reads a sentence, so there was no
142
- // counterpart to coach and four consecutive reviews found the same wall. The
143
- // ADR's decision is a fifth ADVISORY 'reader' lens, scoped to copy-bearing diffs
144
- // by this file's own hasCopyOrVisualWork, built only after the instance writes a
145
- // voice guide to judge against. Until it exists the step asks for the RECORD
146
- // instead of the coaching: followable today, and it is the corpus both the guide
147
- // and the lens will be written from. When the reader lands, this reverts to
148
- // coaching a lens that is actually listening — and the wording is the signal for
149
- // which of those two worlds we are in, so do not restore the old sentence early.
138
+ // STEP 1 ASKS FOR COACHING AGAIN, because there is now a lens listening.
139
+ //
140
+ // The history, because the wording is load-bearing: STEP 1 originally said
141
+ // "coach the grader", which asked for something no artist could do (task
142
+ // 1003937, ADR 0298) — the panel's four lenses read completion, code quality,
143
+ // security and runtime cost, and not one read a sentence, so four consecutive
144
+ // reviews found the same wall. The step was rewritten to ask for the RECORD
145
+ // instead, and that version carried an explicit instruction not to restore the
146
+ // coaching wording early, because the wording is the signal for which world we
147
+ // are in.
148
+ //
149
+ // Task 1003977 shipped the fifth lens: an ADVISORY 'reader', scoped to
150
+ // copy-bearing diffs by THIS FILE'S hasCopyOrVisualWork — the same predicate
151
+ // that fires this cascade, so an artist review and the copy lens cover exactly
152
+ // the same set by construction, and widening one widens both. The voice guide
153
+ // it waited on is docs/voice.md (task 1003976). So there is a counterpart to
154
+ // coach, it has a written standard to be coached against, and the record the
155
+ // interim wording collected is what both were built from.
156
+ //
157
+ // What has NOT changed: the reader is non-gating and cannot hold a ship (ADR
158
+ // 0241 keeps the artist's judgement on the deploy), so this review is still a
159
+ // request triage may dismiss.
150
160
  function artistReviewBody(task) {
151
161
  return [
152
162
  `This is an ARTIST REVIEW, generated automatically because task ${task.id} put copy or visuals in front of a person. It is a REQUEST, not a gate — it blocks nothing that shipped, and triage may dismiss it.`,
153
163
  '',
154
- `STEP 1 — READ THE GRADE, AND WRITE DOWN WHAT IT COULD NOT SEE. Pull how the grader judged task ${task.id} (GET /tasks/${task.id}?include=grade), then record what an artist would have caught and no lens did: slop, an off-voice line, a claim that does not land, a visual that reads as generated. Do not expect to find a copy verdict to argue with — the panel's four lenses read completion, code quality, security and runtime cost, and none of them reads a sentence (ADR 0298). Your record IS the compounding part: it is the evidence the advisory 'reader' lens is being built from, and the corpus the voice guide it waits on will be written against.`,
164
+ `STEP 1 — COACH THE READER. Pull how the grader judged task ${task.id} (GET /tasks/${task.id}?include=grade) and find the 'reader' row in signals.workers, with its copy_score in rubric_scores. That lens read the changed copy against docs/voice.md and cited rule ids. Say where it was RIGHT, where it was WRONG, and — the valuable one — what it did not see at all. A finding it filed that you disagree with is worth as much as one it missed: both are corrections to a lens that is listening, which is what this step could not ask for before task 1003977 shipped it (ADR 0298). If the miss is because no rule in docs/voice.md reaches it, that is a gap in the GUIDE — file it against the guide rather than expecting the lens to invent a rule at grade time. Two cases where there will be no reader row and that is not a fault: a ship that took the trivial-diff path (a single generalist shot, no panel), and one where the lens errored — it is advisory, so an outage is recorded and nothing is blocked.`,
155
165
  '',
156
166
  'STEP 2 — REMAKE OR TWEAK the copy/visuals if they need it. Your choice of route, both offered on purpose: OUTSIDE the AI chat (edit the surface directly and land it through the copy desk or the normal pipeline), or WITH it (draft in chat, then land). Either way it lands as a claimed, graded, shipped task like everything else — there is no live CMS (goal 1000074\'s hard rule).',
157
167
  '',
@@ -213,15 +213,24 @@ await test('a copy/visual ship generates the two-step artist review', () => {
213
213
  assert.match(spec.title, /7001/);
214
214
  // Both steps, and both routes of step 2, are in the body.
215
215
  //
216
- // STEP 1's label tracks ADR 0298 (task 1003937). It used to read
217
- // "REVIEW THE GRADER … coach it", which asked for something no artist could
218
- // do: the panel's four lenses read completion, code quality, security and
219
- // runtime cost, so there was no copy verdict to coach. Until the advisory
220
- // reader worker ships (task 1003977, itself gated on the voice guide in
221
- // task 1003976) the step asks for the RECORD instead — hence the pin on the
222
- // new label AND on the ADR reference, which is what stops a well-meaning
223
- // copy-edit from restoring the unfollowable version without reading why.
224
- assert.match(spec.description, /STEP 1 — READ THE GRADE, AND WRITE DOWN WHAT IT COULD NOT SEE/);
216
+ // STEP 1's label tracks ADR 0298 (task 1003937), and it has now turned twice.
217
+ // It first read "REVIEW THE GRADER … coach it", which asked for something no
218
+ // artist could do: the panel's four lenses read completion, code quality,
219
+ // security and runtime cost, so there was no copy verdict to coach. It was
220
+ // rewritten to ask for the RECORD instead, as an explicitly INTERIM wording,
221
+ // pinned here so a well-meaning copy-edit could not restore the unfollowable
222
+ // version without reading why.
223
+ //
224
+ // Task 1003977 shipped the advisory reader lens and the voice guide it waited
225
+ // on (task 1003976), so the step is coaching again — and this pin moves with
226
+ // it. The two must turn TOGETHER: the wording is the signal for which world
227
+ // we are in, so a body that still said "no lens reads a sentence" after the
228
+ // lens landed would send every artist looking for a counterpart that is
229
+ // sitting right there in the grade record.
230
+ assert.match(spec.description, /STEP 1 — COACH THE READER/);
231
+ assert.match(spec.description, /docs\/voice\.md/, 'and names the written standard to coach against');
232
+ assert.doesNotMatch(spec.description, /none of them reads a sentence/,
233
+ 'the interim claim is gone — it stopped being true when the reader shipped');
225
234
  assert.match(spec.description, /ADR 0298/, 'the step names the decision that shaped it');
226
235
  assert.match(spec.description, /STEP 2 — REMAKE OR TWEAK/);
227
236
  assert.match(spec.description, /OUTSIDE the AI chat/);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.889",
3
+ "version": "1.19.890",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.889",
9
+ "version": "1.19.890",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.889",
3
+ "version": "1.19.890",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -12,6 +12,14 @@ const crypto = require('node:crypto');
12
12
  const { spawn } = require('node:child_process');
13
13
  const { cliClient } = require('./cli-lib');
14
14
  const grader = require('../../modules/grading/grader');
15
+ // task 1003977: the ONE definition of "does this ship put copy in front of a
16
+ // person". It lives in the lifecycle module and the grading module may not
17
+ // require a sibling (ADR 0083), so this script — which is not a module and
18
+ // already requires grading directly — is the seam where the two meet. Writing a
19
+ // second predicate inside grading would let the at-ship reader lens and the
20
+ // after-the-fact artist review disagree about what they cover, and widening the
21
+ // set would then have to be done twice.
22
+ const { hasCopyOrVisualWork } = require('../../modules/lifecycle/cascade.js');
15
23
  const { resolveBuilderModel } = require('./ship-memory-sync.js');
16
24
  const { argvOut, gitArgvOk, gitOk, shellOk } = require('./ship-git.js');
17
25
  const { GRADER_DIFF_CHAR_CAP, GRADER_TIMEOUT_MS, REPO_ROOT, noteServerReward } = require('./ship-state.js');
@@ -285,9 +293,16 @@ async function runSubagentGrader({ task, valueSummary, baseline, changedFiles: p
285
293
  lenses_dropped: ['hacker', 'efficiency'],
286
294
  };
287
295
  } else {
288
- const panel = grader.WORKERS.DEFAULT_PANEL;
296
+ // task 1003977: the advisory Reader rides along on copy-bearing diffs only.
297
+ // Where the predicate says no, `panel` is byte-identical to DEFAULT_PANEL
298
+ // and the ship costs exactly what it costs today.
299
+ const copyBearing = hasCopyOrVisualWork(task);
300
+ const panel = grader.WORKERS.panelFor({ copyBearing });
289
301
  console.log(` subagent grader ${grader.VERSION}: panel of ${panel.length} workers (${panel.join(', ')}), model=${grader.selectGraderModel({ builderModel })} (builder ${builderModel || `${grader.ASSUMED_BUILDER_MODEL} assumed`}), changed_files=${changedFiles.length}, diff_chars=${diff.length}, per-worker timeout=${GRADER_TIMEOUT_MS}ms`);
290
- console.log(` gate: every worker must return verdict='pass'; 'concerns' → manual-confirm fall-through; 'fail' blocks`);
302
+ console.log(` gate: every GATING worker must return verdict='pass'; 'concerns' → manual-confirm fall-through; 'fail' blocks`);
303
+ console.log(copyBearing
304
+ ? ` copy lens: ON — this task is copy-bearing, so the advisory 'reader' reads the changed strings against docs/voice.md. It is NON-GATING: nothing it reports, and no error or timeout it hits, can stop this ship.`
305
+ : ` copy lens: off — not a copy-bearing task (no copy-proposal source, not artist-discipline, module not in COPY_VISUAL_MODULES), so the panel is the usual four.`);
291
306
  if (priorRound) {
292
307
  console.log(` delta re-grade (task 1002662): prior round's findings + builder response ride along, fenced; answered findings can only re-veto with NEW evidence`);
293
308
  }
@@ -300,8 +315,19 @@ async function runSubagentGrader({ task, valueSummary, baseline, changedFiles: p
300
315
  builderRank, // SEC #863: drives the permission-path pre-pass
301
316
  builderModel, // task 1002659: real session model → cross-model grader pick
302
317
  priorRound, // task 1002662: delta-aware re-grade context (panel path only)
318
+ workers: panel, // task 1003977: DEFAULT_PANEL, plus the advisory reader when copy-bearing
303
319
  runOpts,
304
320
  });
321
+ // A SKIPPED LENS IS RECORDED, NEVER SILENT. Without this a grade with no
322
+ // copy_score is indistinguishable from one where the copy was read and
323
+ // found clean — a missing score would read as a passing one. Same pattern
324
+ // and same reason as the trivial path's `lenses_dropped` above.
325
+ if (!copyBearing) {
326
+ result.signals = {
327
+ ...(result.signals || {}),
328
+ reader_skipped: 'not_copy_bearing',
329
+ };
330
+ }
305
331
  }
306
332
  const wallMs = Date.now() - t0;
307
333
  // Decorate signals with the local shortstat — useful in audit-trail.
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.889'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.890'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -128,11 +128,19 @@ test('nothing parseable is null', () => {
128
128
  test('GRADE_SHAPE_KEYS is the exported score-key list', () => {
129
129
  // Exported so workers and grader.js agree on what "grade-shaped" means; the
130
130
  // preference test above depends on functional_fidelity being in this list.
131
+ //
132
+ // ONE ENTRY PER WORKER THAT REPORTS A SCORE. task 1003977 added copy_score
133
+ // with the reader: reader.js re-exports this extractJson, so a worker missing
134
+ // from this list silently loses the score-based disambiguation every other
135
+ // worker gets. Kept as an exact list rather than loosened, because that
136
+ // silence is the whole reason to pin it — a new worker must appear here in
137
+ // the same change that adds it.
131
138
  assert.deepEqual(GRADE_SHAPE_KEYS, [
132
139
  'functional_fidelity',
133
140
  'code_quality',
134
141
  'security_score',
135
142
  'efficiency_score',
143
+ 'copy_score',
136
144
  ]);
137
145
  });
138
146
 
@@ -0,0 +1,257 @@
1
+ // tests/grader_reader_lens.mjs — the advisory Reader lens (task 1003977, ADR 0298).
2
+ //
3
+ // The owner settled three terms on 2026-09-17 and each is pinned here:
4
+ // SCOPE copy-bearing diffs only, keyed off the ONE existing predicate.
5
+ // AUTHORITY advisory, full stop — it cannot fail, cannot reach 'concerns',
6
+ // and cannot block via an error or a timeout.
7
+ // STANDARD docs/voice.md, cited by rule id.
8
+ //
9
+ // The task names the error exemption as "the single most important test in this
10
+ // task", because a worker error normally HARD-FAILS the panel
11
+ // (panel_gate: 'ANY worker.error != null → passed=false'). It is §2 below.
12
+ //
13
+ // The subtlest case is §3: a HEALTHY reader must not mask a total grader
14
+ // outage. Before the non-gating partition, a passing fifth worker would make
15
+ // "every gating worker is missing signal" false, and a total outage would
16
+ // present as FAIL · 0/10 with every axis null — the exact regression class
17
+ // task 1003574 documents, where a builder sat dark on an unclearable re-grade.
18
+
19
+ import { test } from 'node:test';
20
+ import assert from 'node:assert/strict';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { createRequire } from 'node:module';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
+ const grader = require('../modules/grading/grader.js');
29
+ const WORKERS = require('../modules/grading/grader-workers/index.js');
30
+ const reader = require('../modules/grading/grader-workers/reader.js');
31
+ const { hasPanelEvidence } = require('../modules/grading/grader-score.js');
32
+ const { hasCopyOrVisualWork } = require('../modules/lifecycle/cascade.js');
33
+
34
+ const W = (kind, verdict, extra = {}) => ({
35
+ kind, name: kind, verdict, scores: {}, issues: [], confidence: 0.9, error: null, ...extra,
36
+ });
37
+ const healthyFour = () => [
38
+ W('narc', 'pass', { scores: { functional_fidelity: 9 } }),
39
+ W('quality', 'pass', { scores: { code_quality: 9, economy: 9 } }),
40
+ W('hacker', 'pass', { scores: { security_score: 9 } }),
41
+ W('efficiency', 'pass', { scores: { efficiency_score: 9 } }),
42
+ ];
43
+ const agg = (ws) => grader.aggregatePanel(ws, {});
44
+
45
+ // ---- 1. AUTHORITY: the verdict itself cannot be a blocking one -------------
46
+
47
+ test('computeVerdict returns "advisory" on EVERY path, including its own failures', () => {
48
+ // Structural, not a filter: there is no branch that returns pass/concerns/fail,
49
+ // so "it cannot block" is a property of the function rather than a promise
50
+ // kept by its callers.
51
+ const paths = [
52
+ ['clean', [{ copy_score: 10, issues: [] }, {}]],
53
+ ['bad copy', [{ copy_score: 0, issues: [{ severity: 'major', kind: 'break', rule: 'V1' }] }, {}]],
54
+ ['subagent error', [null, { subprocess_error: 'SUBAGENT_TIMEOUT' }]],
55
+ ['spawn failure', [null, { subprocess_error: 'SUBAGENT_SPAWN_FAILED' }]],
56
+ ['unparseable', ['not json at all', {}]],
57
+ ['schema error', [{ copy_score: 'banana', issues: [] }, {}]],
58
+ ['null parse', [null, {}]],
59
+ ];
60
+ for (const [name, [parsed, meta]] of paths) {
61
+ const v = reader.computeVerdict(parsed, meta);
62
+ assert.equal(v.verdict, 'advisory', `${name} → ${v.verdict}`);
63
+ assert.equal(v.verdict, reader.ADVISORY_VERDICT);
64
+ }
65
+ });
66
+
67
+ test('an errored Reader reports NO score rather than a zero', () => {
68
+ // 0/10 is a real reading ("would embarrass the project in front of a user").
69
+ // An outage is missing signal, not a bad signal — the distinction #943 draws
70
+ // for the whole panel, applied to one worker.
71
+ const v = reader.computeVerdict(null, { subprocess_error: 'SUBAGENT_TIMEOUT' });
72
+ assert.deepEqual(v.scores, {});
73
+ assert.equal(v.error, 'SUBAGENT_TIMEOUT');
74
+ });
75
+
76
+ test('a blocker severity is FLOORED to major, not dropped', () => {
77
+ // Dropping would hide a real observation; honouring would hand this lens the
78
+ // veto the owner's term denies it.
79
+ const v = reader.computeVerdict({ copy_score: 3, issues: [{ severity: 'blocker', kind: 'break', rule: 'V2' }] }, {});
80
+ assert.equal(v.issues.length, 1, 'the finding survives');
81
+ assert.equal(v.issues[0].severity, 'major');
82
+ assert.equal(v.issues[0].severity_floored_from, 'blocker');
83
+ });
84
+
85
+ // ---- 2. THE EXEMPTION THE TASK CALLS THE MOST IMPORTANT --------------------
86
+
87
+ test('NO Reader outcome can stop a ship — including an error, which normally hard-fails', () => {
88
+ const baseline = agg(healthyFour());
89
+ assert.equal(baseline.passed, true);
90
+
91
+ const cases = {
92
+ 'clean reader': W('reader', 'advisory', { scores: { copy_score: 9 } }),
93
+ 'copy_score 0': W('reader', 'advisory', { scores: { copy_score: 0 } }),
94
+ 'errored (timeout)': W('reader', 'advisory', { error: 'SUBAGENT_TIMEOUT' }),
95
+ 'errored (prompt too long)': W('reader', 'advisory', { error: 'PROMPT_TOO_LONG' }),
96
+ 'two major findings': W('reader', 'advisory', { scores: { copy_score: 2 }, issues: [{ severity: 'major' }, { severity: 'major' }] }),
97
+ // Belt AND braces: even if a future edit made the worker emit a gating
98
+ // verdict, the panel partition still refuses to read it.
99
+ 'ILLEGALLY emits fail': W('reader', 'fail', { issues: [{ severity: 'blocker' }] }),
100
+ };
101
+ for (const [name, row] of Object.entries(cases)) {
102
+ const r = agg([...healthyFour(), row]);
103
+ assert.equal(r.passed, true, `${name} must not block the ship`);
104
+ assert.equal(r.signals.panel_outcome, 'pass', `${name} must not move the outcome`);
105
+ }
106
+ });
107
+
108
+ test('the Reader is absent from every gate count', () => {
109
+ const r = agg([...healthyFour(), W('reader', 'advisory', { error: 'SUBAGENT_TIMEOUT' })]);
110
+ assert.equal(r.signals.worker_count, 4, 'the gate denominator counts gating workers only');
111
+ assert.equal(r.signals.error_count, 0, "an errored Reader is not a gate error");
112
+ assert.equal(r.signals.fail_count, 0);
113
+ assert.equal(r.signals.pass_count, 4);
114
+ });
115
+
116
+ // ---- 3. THE SUBTLE ONE -----------------------------------------------------
117
+
118
+ test('a HEALTHY Reader does not mask a total grader outage', () => {
119
+ const allErrored = ['narc', 'quality', 'hacker', 'efficiency'].map((k) => W(k, 'fail', { error: 'SUBAGENT_SPAWN_FAILED' }));
120
+ const without = agg(allErrored);
121
+ assert.equal(without.signals.panel_outcome, 'unavailable', 'baseline: a total outage is an outage');
122
+
123
+ const with_ = agg([...allErrored, W('reader', 'advisory', { scores: { copy_score: 9 } })]);
124
+ assert.equal(with_.signals.panel_outcome, 'unavailable',
125
+ 'a passing fifth worker must not turn an outage into a FAIL 0/10 (the task-1003574 class)');
126
+ assert.equal(with_.passed, false);
127
+ });
128
+
129
+ test("the Reader's confidence cannot move the server-gate median", () => {
130
+ const four = healthyFour();
131
+ const noReader = agg(four).signals.confidence;
132
+ const withReader = agg([...four, W('reader', 'advisory', { confidence: 0.01, scores: { copy_score: 1 } })]).signals.confidence;
133
+ assert.equal(withReader, noReader, 'a low-confidence Reader must not drag the forwarded confidence');
134
+ });
135
+
136
+ // ---- 4. THE SIGNAL STILL LANDS ---------------------------------------------
137
+
138
+ test('copy_score and the findings reach the RECORD even though the gate ignores them', () => {
139
+ const r = agg([...healthyFour(), W('reader', 'advisory', { scores: { copy_score: 4 }, issues: [{ severity: 'major', rule: 'V1' }] })]);
140
+ assert.equal(r.rubric_scores.copy_score, 4, 'the score is recorded');
141
+ assert.ok(r.signals.workers.some((w) => w.kind === 'reader'), 'the Reader row is in the record');
142
+ assert.equal(agg(healthyFour()).rubric_scores.copy_score, null, 'and is null when no Reader ran');
143
+ });
144
+
145
+ test('a Reader row still counts as evidence that the panel ran', () => {
146
+ // hasPanelEvidence is an `every` over the worker verdicts. Leave 'advisory'
147
+ // out of WORKER_VERDICTS and a fully-healthy five-worker panel reads as NO
148
+ // evidence, firing the implausible-pass guard on exactly the ships carrying
149
+ // the most lenses.
150
+ const rows = (extra = []) => ({ workers: [
151
+ { kind: 'narc', verdict: 'pass' }, { kind: 'quality', verdict: 'pass' },
152
+ { kind: 'hacker', verdict: 'pass' }, { kind: 'efficiency', verdict: 'pass' }, ...extra,
153
+ ] });
154
+ assert.equal(hasPanelEvidence(rows()), true);
155
+ assert.equal(hasPanelEvidence(rows([{ kind: 'reader', verdict: 'advisory' }])), true);
156
+ assert.equal(hasPanelEvidence(rows([{ kind: 'reader', verdict: 'nonsense' }])), false, 'the vocabulary is still closed');
157
+ });
158
+
159
+ // ---- 5. SCOPE --------------------------------------------------------------
160
+
161
+ test('the panel is byte-identical to today when the diff is not copy-bearing', () => {
162
+ assert.deepEqual(WORKERS.panelFor({ copyBearing: false }), WORKERS.DEFAULT_PANEL);
163
+ assert.deepEqual(WORKERS.panelFor(), WORKERS.DEFAULT_PANEL, 'and when the caller says nothing');
164
+ assert.deepEqual(WORKERS.panelFor({ copyBearing: true }), [...WORKERS.DEFAULT_PANEL, 'reader']);
165
+ });
166
+
167
+ test('scope keys off the ONE existing predicate, not a second copy', () => {
168
+ // The at-ship lens and the after-the-fact artist review must never disagree
169
+ // about what they cover. Widening COPY_VISUAL_MODULES must widen both at once,
170
+ // which it only does while there is one predicate.
171
+ const src = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'ship-grade.js'), 'utf8');
172
+ assert.match(src, /hasCopyOrVisualWork/, 'the caller asks lifecycle for the answer');
173
+ assert.match(src, /panelFor\(\{ copyBearing \}\)/, 'and passes it down');
174
+ // RECURSIVE. A readdirSync of modules/grading alone skips grader-workers/,
175
+ // which holds reader.js — the file this assertion is most about. The first
176
+ // version of this test did exactly that and proved nothing it claimed.
177
+ const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
178
+ const full = path.join(dir, e.name);
179
+ return e.isDirectory() ? walk(full) : (e.name.endsWith('.js') ? [full] : []);
180
+ });
181
+ const gradingFiles = walk(path.join(ROOT, 'modules', 'grading'));
182
+ assert.ok(gradingFiles.some((f) => f.endsWith(`grader-workers${path.sep}reader.js`)),
183
+ 'the scan reaches grader-workers/reader.js — otherwise it checks everything except the file in question');
184
+ for (const f of gradingFiles) {
185
+ const body = fs.readFileSync(f, 'utf8').replace(/^\s*\/\/.*$/gm, '');
186
+ assert.ok(!/COPY_VISUAL_MODULES|copy-proposal/.test(body),
187
+ `${path.relative(ROOT, f)} must not re-implement the copy predicate — grading cannot require lifecycle (ADR 0083), so the CALLER decides`);
188
+ }
189
+ // Sanity: the predicate really does discriminate.
190
+ assert.equal(hasCopyOrVisualWork({ discipline: 'artist' }), true);
191
+ assert.equal(hasCopyOrVisualWork({ module_key: 'hall-ui' }), true);
192
+ assert.equal(hasCopyOrVisualWork({ module_key: 'grading', discipline: 'engineer' }), false);
193
+ });
194
+
195
+ // ---- 6. STANDARD -----------------------------------------------------------
196
+
197
+ test('the prompt sends the worker to the written guide, and forbids an uncited finding', () => {
198
+ const p = reader.PROMPT_HEADER;
199
+ assert.match(p, /docs\/voice\.md/, 'the rubric is the voice guide');
200
+ assert.match(p, /cites no rule id is not a finding/i, "the guide's own rule travels with it");
201
+ // The hard/judgement split is what stops an advisory lens recording taste as
202
+ // fact, so it is pinned rather than left to the prompt's good intentions.
203
+ assert.match(p, /V1-V5/, 'hard rules may be asserted');
204
+ assert.match(p, /V6-V11/, 'judgement rules may only be flagged');
205
+ assert.match(p, /ADVISORY/, 'the worker is told it cannot block');
206
+ assert.ok(!/blocker/.test(p.split('## Return ONLY JSON')[1] || ''),
207
+ 'the JSON schema offers no blocker severity');
208
+ });
209
+
210
+ test('docs/voice.md exists — the lens has a standard to judge against', () => {
211
+ // The hard dependency (task 1003976). A copy lens with no written standard
212
+ // records taste and calls it a finding.
213
+ const guide = fs.readFileSync(path.join(ROOT, 'docs', 'voice.md'), 'utf8');
214
+ for (const id of ['V1', 'V2', 'V3', 'V4', 'V5']) {
215
+ assert.ok(guide.includes(`## ${id} —`) || guide.includes(`## ${id} `), `${id} is a rule in the guide`);
216
+ }
217
+ });
218
+
219
+ // ---- 7. THE DONE-WHEN CLAUSE THAT LIVES IN ANOTHER FILE ---------------------
220
+
221
+ test('the artist review now coaches the reader, because there is a lens listening', () => {
222
+ // The task's own DONE WHEN: "Then STEP 1 of the artist review reverts to true
223
+ // coaching — see the comment in modules/lifecycle/cascade.js, which says so
224
+ // and asks not to be reverted early." The interim wording was explicitly a
225
+ // signal for which world we are in, so shipping the lens without turning it
226
+ // leaves the instruction to artists describing a panel that no longer exists.
227
+ const { artistReviewBody } = require('../modules/lifecycle/cascade.js');
228
+ const body = artistReviewBody({ id: '1234' });
229
+ assert.match(body, /COACH THE READER/, 'STEP 1 asks for coaching again');
230
+ assert.match(body, /docs\/voice\.md/, 'and names the standard to coach against');
231
+ assert.ok(!/none of them reads a sentence/.test(body),
232
+ 'the interim claim that no lens reads a sentence is gone — it is no longer true');
233
+ // Still a request, never a gate (ADR 0241) — unchanged by this task.
234
+ assert.match(body, /REQUEST, not a gate/);
235
+ });
236
+
237
+ test('the cascade and the copy lens cover the SAME set by construction', () => {
238
+ // Both key off hasCopyOrVisualWork, so an artist review exists for exactly
239
+ // the ships a reader ran on. If these ever diverge, an artist is asked to
240
+ // coach a lens that never looked.
241
+ const cascadeSrc = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'cascade.js'), 'utf8');
242
+ const shipSrc = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'ship-grade.js'), 'utf8');
243
+ assert.match(cascadeSrc, /hasCopyOrVisualWork/);
244
+ assert.match(shipSrc, /hasCopyOrVisualWork/);
245
+ });
246
+
247
+ // ---- 8. COST ---------------------------------------------------------------
248
+
249
+ test('the Reader sits at the heavy timeout tier, so it cannot change worst-case wall time', () => {
250
+ // The four already sit at 360s and workers run in parallel, so the panel's
251
+ // wall time is max() and a fifth at the same budget does not move it.
252
+ const t = WORKERS.timeoutForWorker(reader, {});
253
+ assert.equal(t, 360_000);
254
+ for (const k of ['narc', 'quality', 'hacker', 'efficiency']) {
255
+ assert.equal(WORKERS.timeoutForWorker(WORKERS.REGISTRY[k], {}), t, `${k} shares the tier`);
256
+ }
257
+ });