@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 +30 -20
- package/docs/module-api-changelog.md +2 -0
- package/modules/grading/grader-rubric.json +17 -7
- package/modules/grading/grader-score.js +7 -1
- package/modules/grading/grader-workers/extract-json.js +7 -1
- package/modules/grading/grader-workers/index.js +43 -0
- package/modules/grading/grader-workers/reader.js +218 -0
- package/modules/grading/grader.js +47 -11
- package/modules/lifecycle/cascade.js +23 -13
- package/modules/lifecycle/tests/cascade.mjs +18 -9
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/ship-grade.js +28 -2
- package/src/module-api.js +1 -1
- package/tests/extract_json.mjs +8 -0
- package/tests/grader_reader_lens.mjs +257 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
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:
|
|
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":
|
|
14
|
+
"functional_verbatim": 2271,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
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": "
|
|
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": "
|
|
4445
|
+
"sha256": "de0ca17fedb33bd54d73759e14e6813cd9f2c8c85ef2180e49c54794c7a63a3d"
|
|
4446
4446
|
},
|
|
4447
4447
|
{
|
|
4448
4448
|
"path": "modules/grading/grader-score.js",
|
|
4449
4449
|
"mode": "0000644",
|
|
4450
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
7710
|
+
"sha256": "f9267b40d96aa3395f93b456da92cb8ff99f579340e1e9ac57e2264764e7b293"
|
|
7706
7711
|
},
|
|
7707
7712
|
{
|
|
7708
7713
|
"path": "package.json",
|
|
7709
7714
|
"mode": "0000644",
|
|
7710
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
135
|
-
const concernsCount =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
192
|
-
const gating = nonAdvisory.length ? nonAdvisory :
|
|
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 =
|
|
232
|
-
const advisoryConcerns =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
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 —
|
|
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)
|
|
217
|
-
// "REVIEW THE GRADER … coach it", which asked for something no
|
|
218
|
-
// do: the panel's four lenses read completion, code quality,
|
|
219
|
-
// runtime cost, so there was no copy verdict to coach.
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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');
|
package/tests/extract_json.mjs
CHANGED
|
@@ -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
|
+
});
|