@bongos/core 1.19.332 → 1.19.334

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.bongos-core.json +173 -43
  2. package/docs/adr/0187-oauth-handshake-cookie-lifetime.md +84 -0
  3. package/docs/adr/README.md +1 -0
  4. package/docs/api/openapi.json +2 -2
  5. package/docs/api-reference.md +1 -1
  6. package/docs/copy-inventory.md +202 -202
  7. package/docs/copy-registry.json +384 -384
  8. package/modules/grading/grader-config.js +98 -0
  9. package/modules/grading/grader-model.js +150 -0
  10. package/modules/grading/grader-prompt.js +139 -0
  11. package/modules/grading/grader-score.js +706 -0
  12. package/modules/grading/grader-subagent.js +328 -0
  13. package/modules/grading/grader.js +5 -1287
  14. package/modules/hall-ui/public/builders.js +19 -1182
  15. package/modules/hall-ui/public/goals-page.js +11 -997
  16. package/modules/hall-ui/public/goals-render.js +1032 -0
  17. package/modules/hall-ui/public/goals.html +4 -0
  18. package/modules/hall-ui/public/hall-render.js +1211 -0
  19. package/modules/hall-ui/public/index.html +6 -0
  20. package/modules/lifecycle/routes/goal-route-authz.js +400 -0
  21. package/modules/lifecycle/routes/goals.js +1 -367
  22. package/modules/lifecycle/routes/task-route-helpers.js +219 -0
  23. package/modules/lifecycle/routes/task-write-routes.js +542 -0
  24. package/modules/lifecycle/routes/tasks.js +3 -696
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/scripts/gds/box-infra.js +280 -0
  28. package/scripts/gds/box.js +1 -245
  29. package/scripts/gds/fitness-checks-identity.js +320 -0
  30. package/scripts/gds/fitness-checks-vocabulary.js +230 -0
  31. package/scripts/gds/fitness-checks-write-validation.js +144 -0
  32. package/scripts/gds/fitness-lib.js +82 -0
  33. package/scripts/gds/fitness.js +55 -668
  34. package/scripts/gds/provision-config.js +204 -0
  35. package/scripts/gds/provision-net.js +221 -0
  36. package/scripts/gds/provision-repo.js +849 -0
  37. package/scripts/gds/provision-units.js +262 -0
  38. package/scripts/gds/provision.js +4 -1395
  39. package/src/bongos/auth-admission.js +264 -0
  40. package/src/bongos/auth-config.js +151 -0
  41. package/src/bongos/auth-discord.js +198 -0
  42. package/src/bongos/auth-github.js +336 -0
  43. package/src/bongos/auth-idp.js +330 -0
  44. package/src/bongos/auth.js +5 -1100
  45. package/src/bongos/routes/auth.js +54 -10
  46. package/src/module-api.js +5 -1
  47. package/tests/auth_admission_gate.mjs +3 -1
  48. package/tests/auth_web_recovery.mjs +63 -0
  49. package/tests/est_advisory.mjs +3 -1
  50. package/tests/goal_advisory.mjs +3 -2
  51. package/tests/goal_criterion_confirm_ui.mjs +6 -0
  52. package/tests/goal_subtitle.mjs +5 -2
  53. package/tests/goals_card_view.mjs +10 -3
  54. package/tests/grader_generated_provenance.mjs +3 -1
  55. package/tests/hall_palette.mjs +3 -1
  56. package/tests/helpers.mjs +58 -25
  57. package/tests/lib_sh_resolution.mjs +3 -1
  58. package/tests/membership_kind_lockstep.mjs +3 -1
  59. package/tests/migration_allocation.mjs +5 -2
  60. package/tests/patch_gate_ordering.mjs +3 -1
  61. package/tests/rank_tier_single_source.mjs +16 -3
  62. package/tests/reward_gate.mjs +3 -2
@@ -0,0 +1,706 @@
1
+ // modules/grading/grader-score.js — turning a worker's raw JSON into a score:
2
+ // shape validation, substantive-issue detection, the SERVER-side guards that a
3
+ // model cannot talk its way past, and multi-shot aggregation.
4
+ //
5
+ // Carved out of modules/grading/grader.js by task 1003206 (goal 1000079, the R03
6
+ // de-monolith plan) to bring the grader under the 1,500-line ratchet. A STRUCTURAL
7
+ // move: bodies unchanged, grader.js requires every name back, so the grade port
8
+ // surface and the panel's behaviour are exactly as before.
9
+
10
+ 'use strict';
11
+
12
+ const path = require('node:path');
13
+ const fs = require('node:fs');
14
+ const { DEFAULT_GRADER_MODEL, FALLTHROUGH_CONFIDENCE, LOW_CONFIDENCE, THRESHOLD, VERSION, permissionPathCheck, routeRankCheck } = require('./grader-config.js');
15
+ const { TRIVIAL_MAX_FILES, TRIVIAL_MAX_LINES, clamp, median, r1 } = require('./grader-model.js');
16
+ const { extractJson } = require('./grader-prompt.js');
17
+
18
+ function isNum(n) {
19
+ return Number.isFinite(n);
20
+ }
21
+
22
+ // #1002653 corroboration helpers for validateAndScore's null-ff lens branch.
23
+ // A worker entry is untrusted input — w.issues may be any shape. Absent issues
24
+ // on an errored worker is the normal outage shape (treated as none); a truthy
25
+ // NON-ARRAY issues is malformed and must DISQUALIFY corroboration (fail-closed
26
+ // schema_error downstream), never TypeError into a 500 on grade submission.
27
+ const isNarcErroredClean = (w) => {
28
+ if (!w || w.kind !== 'narc' || !w.error) return false;
29
+ const issues = w.issues == null ? [] : w.issues;
30
+ if (!Array.isArray(issues)) return false;
31
+ return !issues.some((i) => i && (i.severity === 'blocker' || i.severity === 'major'));
32
+ };
33
+ const isNarcHealthy = (w) => !!w && w.kind === 'narc' && !w.error;
34
+
35
+ // task 1002658 (ADR 0164): a SUBSTANTIVE issue is one a real panel emits — a
36
+ // known severity + a non-empty summary. The old plausibility guard counted raw
37
+ // issues[].length, so one fabricated `{}` defeated it; substance-counting
38
+ // closes that lazy-forgery class using only the submitted body itself.
39
+ const ISSUE_SEVERITIES = new Set(['blocker', 'major', 'minor', 'nit']);
40
+ function isSubstantiveIssue(it) {
41
+ return !!it && typeof it === 'object'
42
+ && ISSUE_SEVERITIES.has(String(it.severity || '').toLowerCase())
43
+ && typeof it.summary === 'string' && it.summary.trim().length > 0;
44
+ }
45
+
46
+ // task 1002658 (ADR 0164): does the submitted signals block carry the full
47
+ // per-worker shape a real panel run produces — ≥3 workers, each with a kind
48
+ // and a legal verdict (or an error tag)? Client-forgeable like every
49
+ // grade-time input (the ADR's documented honor-system residual for Metic+);
50
+ // its job is separating "the panel ran and unanimously found nothing" (the
51
+ // guard's confirmed false-positive class) from "no evidence at all".
52
+ const WORKER_VERDICTS = new Set(['pass', 'concerns', 'fail']);
53
+ function hasPanelEvidence(signals) {
54
+ const ws = Array.isArray(signals?.workers) ? signals.workers : null;
55
+ if (!ws || ws.length < 3) return false;
56
+ return ws.every((w) => !!w && typeof w === 'object'
57
+ && typeof w.kind === 'string' && w.kind.trim().length > 0
58
+ && (WORKER_VERDICTS.has(String(w.verdict || '').toLowerCase()) || !!w.error));
59
+ }
60
+
61
+ // Validates ONE shot's subagent JSON and produces a per-shot grade-result.
62
+ // Never throws — malformed inputs produce a passed=false result with notes_md.
63
+ // The downstream aggregateShots collapses N of these into a final verdict.
64
+ function validateAndScore(rawSubagent, meta = {}) {
65
+ const parsed =
66
+ typeof rawSubagent === 'string' ? extractJson(rawSubagent) : rawSubagent;
67
+
68
+ const signals = {
69
+ grader_kind: 'subagent',
70
+ grader_version: VERSION,
71
+ model: meta.model || DEFAULT_GRADER_MODEL,
72
+ threshold: THRESHOLD,
73
+ ...meta,
74
+ };
75
+
76
+ if (!parsed || typeof parsed !== 'object') {
77
+ return {
78
+ score: 0,
79
+ passed: false,
80
+ threshold: THRESHOLD,
81
+ grader_kind: 'subagent',
82
+ grader_version: VERSION,
83
+ rubric_scores: {},
84
+ signals: { ...signals, parse_error: true },
85
+ notes_md: `Subagent grader ${VERSION}: could not parse subagent output as JSON.`,
86
+ issues: [],
87
+ };
88
+ }
89
+
90
+ // #1002653: Number(null) === 0, so an errored ADVISORY Narc forwarding an
91
+ // EXPLICIT ff:null fabricated "FAIL — functional_fidelity below 6" at
92
+ // (0+cq)/2. Explicit null = the lens did not run (the #395 confidence
93
+ // precedent): gate/score on the axes that ran. A bare null is NOT enough —
94
+ // a prompt-injected Narc emitting null must stay fail-closed — so the branch
95
+ // needs POSITIVE corroboration: workers[] carries an errored
96
+ // no-substantive-finding Narc (#473 missing-signal semantics) and no healthy
97
+ // Narc. Empty/narc-less/absent workers, ABSENT ff, and every other shape
98
+ // fall through to the fail-closed schema_error below. Quality has no such
99
+ // branch — gating lens, absence stays fail-closed.
100
+ // Trust note: workers[] is client-supplied like every SR-9 grade input — a
101
+ // forger gains nothing here they cannot get by sending ff:9 directly; the
102
+ // server-derivable-signals redesign is tracked separately (SR-9 residual).
103
+ const metaWorkers = Array.isArray(meta.workers) ? meta.workers : null;
104
+ const narcMissingSignal = !!metaWorkers &&
105
+ metaWorkers.some(isNarcErroredClean) && !metaWorkers.some(isNarcHealthy);
106
+ const ffExplicitNull = parsed.functional_fidelity === null;
107
+ const ffLensUnavailable = ffExplicitNull && narcMissingSignal;
108
+ const ff = ffExplicitNull ? null : Number(parsed.functional_fidelity);
109
+ const cq = Number(parsed.code_quality);
110
+ const economyRaw = Number(parsed.economy);
111
+ const economy =
112
+ isNum(economyRaw) && economyRaw >= 0 && economyRaw <= 10 ? economyRaw : null;
113
+ // MAS Phase 2.5 (#423): security_score (from Hacker, Phase 2.1) and
114
+ // efficiency_score (from Efficiency Monger, Phase 2.2). Both ADVISORY —
115
+ // recorded in rubric_scores but do not independently gate the ship. Same
116
+ // back-compat shape as economy: out-of-range / non-numeric / missing →
117
+ // recorded as null, no penalty. Older callers that don't forward these
118
+ // fields grade exactly as before.
119
+ const securityRaw = Number(parsed.security_score);
120
+ const securityScore =
121
+ isNum(securityRaw) && securityRaw >= 0 && securityRaw <= 10 ? securityRaw : null;
122
+ const efficiencyRaw = Number(parsed.efficiency_score);
123
+ const efficiencyScore =
124
+ isNum(efficiencyRaw) && efficiencyRaw >= 0 && efficiencyRaw <= 10 ? efficiencyRaw : null;
125
+ // Treat null/undefined confidence as ABSENT, not as 0. Number(null) === 0,
126
+ // and isNum(0) === true — a naive `isNum(Number(parsed.confidence))` check
127
+ // would accept null as a valid low-confidence signal and FAIL the ship for
128
+ // a missing field. The MAS Phase 1 self-ship (#395) hit this exactly when
129
+ // aggregateShots forwarded a null top-level confidence to the server's
130
+ // validateAndScore. Explicit null-check first; coerce only when present.
131
+ const confidence = parsed.confidence == null
132
+ ? null
133
+ : isNum(Number(parsed.confidence)) ? Number(parsed.confidence) : null;
134
+ const issues = Array.isArray(parsed.issues)
135
+ ? parsed.issues.filter((it) => it && typeof it === 'object').slice(0, 50)
136
+ : [];
137
+ const notesFromSubagent = typeof parsed.notes_md === 'string' ? parsed.notes_md : '';
138
+
139
+ signals.confidence = confidence;
140
+ signals.issue_count = issues.length;
141
+ if (ffLensUnavailable) signals.ff_lens_unavailable = true;
142
+
143
+ if ((!ffLensUnavailable && (!isNum(ff) || ff < 0 || ff > 10)) || !isNum(cq) || cq < 0 || cq > 10) {
144
+ return {
145
+ score: 0,
146
+ passed: false,
147
+ threshold: THRESHOLD,
148
+ grader_kind: 'subagent',
149
+ grader_version: VERSION,
150
+ rubric_scores: {},
151
+ signals: { ...signals, schema_error: true },
152
+ notes_md: `Subagent grader ${VERSION}: subagent returned scores outside [0,10] or non-numeric. Got functional_fidelity=${parsed.functional_fidelity}, code_quality=${parsed.code_quality}.`
153
+ + (ffExplicitNull && !narcMissingSignal
154
+ ? ' Explicit null functional_fidelity without a corroborated Narc outage in signals.workers is fail-closed.'
155
+ : ''),
156
+ issues,
157
+ };
158
+ }
159
+
160
+ const ffR = ffLensUnavailable ? null : r1(clamp(ff, 0, 10));
161
+ const cqR = r1(clamp(cq, 0, 10));
162
+ const economyR = economy != null ? r1(clamp(economy, 0, 10)) : null;
163
+ const securityR = securityScore != null ? r1(clamp(securityScore, 0, 10)) : null;
164
+ const efficiencyR = efficiencyScore != null ? r1(clamp(efficiencyScore, 0, 10)) : null;
165
+ const economyPenalty =
166
+ economyR != null ? Math.max(0, (THRESHOLD - economyR) / 2) : 0;
167
+ const cqEffective = economyR != null ? r1(clamp(cqR - economyPenalty, 0, 10)) : cqR;
168
+ // With the ff lens out, the score reflects only the axis that ran — never a
169
+ // blend with a synthetic 0 (the fabricated 4.5/10) or a synthetic threshold.
170
+ const blended = ffLensUnavailable ? cqR : r1((ffR + cqR) / 2);
171
+
172
+ // confidence must be present (not null) for a PASS. A subagent that omits
173
+ // confidence entirely silently bypassed the low-confidence gate under the
174
+ // old `confidence != null && confidence < LOW_CONFIDENCE` check — null made
175
+ // lowConfidence=false and let high ff/cq scores through unchecked (#489 / #56).
176
+ // Distinguishing "absent" from "low" gives a clear verdict message and leaves
177
+ // the aggregateShots #395 fix intact: shots that omit confidence now FAIL here,
178
+ // so aggregateShots can never reach unanimousPASS with a null confidenceMedian.
179
+ const confidenceAbsent = confidence === null;
180
+ const lowConfidence = !confidenceAbsent && confidence < LOW_CONFIDENCE;
181
+ // ffR is null when the lens is unavailable — it must not fail the gate
182
+ // (null < THRESHOLD is true via JS coercion), and must not appear in the
183
+ // verdict's below-threshold branches.
184
+ const ffBelow = !ffLensUnavailable && ffR < THRESHOLD;
185
+ const bothPass = !ffBelow && cqR >= THRESHOLD;
186
+ const passed = bothPass && !lowConfidence && !confidenceAbsent;
187
+
188
+ const verdict = passed
189
+ ? 'PASS'
190
+ : confidenceAbsent
191
+ ? 'FAIL — confidence absent'
192
+ : lowConfidence
193
+ ? 'FAIL — low confidence'
194
+ : bothPass
195
+ ? 'FAIL'
196
+ : ffBelow && cqR < THRESHOLD
197
+ ? `FAIL — both axes below ${THRESHOLD}`
198
+ : ffBelow
199
+ ? `FAIL — functional_fidelity below ${THRESHOLD}`
200
+ : `FAIL — code_quality below ${THRESHOLD}`;
201
+
202
+ const notesLines = [];
203
+ notesLines.push(`Shot ${signals.shot_index != null ? signals.shot_index : '?'} (${signals.model}) — ${verdict}`);
204
+ notesLines.push(` ff=${ffLensUnavailable ? 'n/a (lens unavailable)' : `${ffR}/10`} cq=${cqR}/10${economyR != null ? ` econ=${economyR}/10 (advisory)` : ''} conf=${confidence ?? '?'}`);
205
+ if (notesFromSubagent) {
206
+ notesLines.push('');
207
+ notesLines.push(notesFromSubagent.trim());
208
+ }
209
+
210
+ return {
211
+ score: blended,
212
+ passed,
213
+ threshold: THRESHOLD,
214
+ grader_kind: 'subagent',
215
+ grader_version: VERSION,
216
+ rubric_scores: {
217
+ functional_fidelity: ffR,
218
+ code_quality: cqR,
219
+ economy: economyR,
220
+ code_quality_effective: cqEffective,
221
+ security_score: securityR,
222
+ efficiency_score: efficiencyR,
223
+ },
224
+ signals,
225
+ notes_md: notesLines.join('\n'),
226
+ issues,
227
+ };
228
+ }
229
+
230
+ // ---------- server-authoritative grade guards (SR-9 / SR-18 / F1 / G5) ----------
231
+ //
232
+ // THE FINDING (SR-9 / SR-18): POST /api/gds/tasks/:id/grade trusts the client.
233
+ // validateAndScore only shape-validates the numbers — `passed` is computed from
234
+ // the CLIENT's submitted scores. The real adversarial panel + the deterministic
235
+ // route-rank / permission-path pre-passes run CLIENT-SIDE in scripts/gds/ship.js;
236
+ // the server never re-runs them. So a builder can POST
237
+ // {functional_fidelity:10, code_quality:10, confidence:1, issues:[]} to self-pass.
238
+ // AND the credit/achievement side-effects (net-negative "Subtractor" bonus,
239
+ // shipper badges) are driven by client-supplied signals.diff_stats +
240
+ // committed_files the server never re-derives (SR-18).
241
+ //
242
+ // WHAT THE SERVER CAN AND CAN'T DO (the boundary — see report):
243
+ // * The web tier is checked out at `main`. The builder's feature-branch
244
+ // commits are NOT on the server at grade time (they only arrive when the
245
+ // task merges to main). So the server CANNOT independently re-derive the
246
+ // committed diff at the claimed head_sha. The diff, diff_stats, and
247
+ // committed_files in the request body remain CLIENT-SUPPLIED.
248
+ // * BUT the server DOES know the caller's LIVE, authoritative rank
249
+ // (req.builder.rank, read fresh from the DB per request — ADR 0016). So the
250
+ // deterministic permission-path pre-pass can run with a TRUSTED rank against
251
+ // the (client-supplied) committed_files — strictly raising the bar.
252
+ // * The route-rank pre-pass reads the route files in the server's OWN
253
+ // checkout (fs) — fully server-authoritative, independent of any client
254
+ // input. (NB: it audits `main`, not the feature branch — the post-push main
255
+ // audit owns feature-branch route changes. Re-running here still hard-fails
256
+ // a forged pass while `main` already carries an ungated write route.)
257
+ //
258
+ // THE GUARD (additive, fail-toward-reject-only): serverSideGuards re-runs the
259
+ // two deterministic pre-passes server-side and applies plausibility/bounding
260
+ // checks. It can ONLY downgrade a client PASS to a non-pass (hard FAIL or
261
+ // manual-confirm); it NEVER upgrades a fail, never relaxes the existing gate,
262
+ // and is a no-op on a client that already reported not-passed. A legitimate
263
+ // honest grade is unaffected.
264
+ //
265
+ // The Archon / GRADER_BYPASS paths do not flow through /grade's passed-gate the
266
+ // way an ordinary ship does (they confirm via /tasks/:id/confirm, which is a
267
+ // separate route), so this guard does not touch them — see the route comment.
268
+
269
+ // Plausibility threshold: a client PASS that reports NO issues on a NON-TRIVIAL
270
+ // diff is not a credible adversarial-panel verdict — the panel is default-fail
271
+ // and every worker must enumerate concrete issues before a pass; a clean pass on
272
+ // a large diff with an empty issues[] is the exact shape of a forged
273
+ // {ff:10,cq:10,issues:[]} self-pass (SR-9). We don't reject it outright (a small
274
+ // honest change can legitimately have no issues) — we DOWNGRADE it to
275
+ // manual-confirm so a human lands it via /confirm. Thresholds mirror the
276
+ // trivial-diff fast-path's spirit (a change past these sizes is "real work"):
277
+ // - more than this many changed files, OR
278
+ // - more than this many added+removed lines
279
+ // makes an empty-issues PASS implausible-as-automatic and forces manual-confirm.
280
+ const IMPLAUSIBLE_PASS_MAX_FILES = TRIVIAL_MAX_FILES; // 3 — same "small change" bar
281
+ const IMPLAUSIBLE_PASS_MAX_LINES = TRIVIAL_MAX_LINES; // 30
282
+
283
+ // Re-derive the size of the change from the submitted committed_files +
284
+ // diff_stats. Both are client-supplied (see boundary note) — we take the
285
+ // STRICTER (larger) of the file-list length and the shortstat files_changed so a
286
+ // forger can't shrink one to dodge the plausibility bar while the other betrays
287
+ // a large diff. Returns { files, lines } with NaN coerced to 0.
288
+ function changeMagnitude({ committedFiles, diffStats } = {}) {
289
+ const listLen = Array.isArray(committedFiles)
290
+ ? committedFiles.filter((f) => typeof f === 'string' && f.trim()).length
291
+ : 0;
292
+ const statFiles = diffStats && Number.isFinite(Number(diffStats.files_changed))
293
+ ? Number(diffStats.files_changed)
294
+ : 0;
295
+ const files = Math.max(listLen, statFiles);
296
+ const lines =
297
+ (Number(diffStats?.lines_added) || 0) + (Number(diffStats?.lines_removed) || 0);
298
+ return { files, lines };
299
+ }
300
+
301
+ // serverSideGuards — the server-authoritative hardening layer (SR-9 / SR-18).
302
+ //
303
+ // Inputs:
304
+ // gradeResult — the output of validateAndScore on the SUBMITTED body. Its
305
+ // `passed` is computed from the client's numbers. We may
306
+ // override it (downgrade only).
307
+ // committedFiles — client-supplied list of committed paths (or null).
308
+ // builderRank — the caller's LIVE rank (req.builder.rank — SERVER-KNOWN,
309
+ // authoritative). Drives the permission-path pre-pass.
310
+ // diffStats — client-supplied { files_changed, lines_added, lines_removed }
311
+ // (gradeResult.signals.diff_stats if not passed explicitly).
312
+ // routesDir — optional override for tests; defaults to the live routes dir.
313
+ //
314
+ // Returns a NEW grade-result (never mutates the input). When a guard trips, the
315
+ // returned result has passed=false and a `server_guard` block in signals naming
316
+ // every triggered guard + the residual notes. When no guard trips, the input is
317
+ // returned with a passed-through `server_guard: { applied: [...], ok: true }`
318
+ // audit marker so the trail shows the guards RAN (and found nothing) rather than
319
+ // being silently absent.
320
+ //
321
+ // CONTRACT: downgrade-only. If the client already reported not-passed, we leave
322
+ // it not-passed (no resurrection). We only ever flip true→false.
323
+ function serverSideGuards(gradeResult, { committedFiles, builderRank, diffStats, routesDir } = {}) {
324
+ const base = gradeResult || {};
325
+ const ds = diffStats || base.signals?.diff_stats || null;
326
+ const guardsTripped = []; // [{ guard, severity, summary }]
327
+ const extraIssues = []; // surfaced into the result's issues[]
328
+ let downgradeTo = null; // 'fail' | 'concerns' | null
329
+
330
+ // The guards only have teeth on a CLIENT PASS — a non-pass is already
331
+ // blocked/fallen-through by the existing gate, and downgrade-only means we
332
+ // never touch it. We still RUN the deterministic checks (their findings are a
333
+ // useful audit signal even on a fail) but only let them flip the verdict when
334
+ // base.passed === true.
335
+ const clientPassed = base.passed === true;
336
+
337
+ // --- Guard 1: deterministic pre-passes, server-side (SR-9 / F1) ---
338
+ // route-rank: reads the server's own route files (fs) — server-authoritative.
339
+ // permission-path: runs with the TRUSTED live rank against the (client) files.
340
+ // Either hard-fail flips a client PASS to a hard FAIL regardless of submitted
341
+ // scores — this is the core "re-run the deterministic checks server-side"
342
+ // hardening. A non-hardFail (e.g. an unclassified READ route → 'concerns') is
343
+ // recorded but does NOT block here: the client-side panel's Hacker already
344
+ // adjudicates read-route intent, and a server-side downgrade-to-concerns on a
345
+ // read route would be a behavior change with no SR-9 security value.
346
+ let routeRank = null;
347
+ try {
348
+ routeRank = routeRankCheck.checkRouteRanks(routesDir ? { routesDir } : {});
349
+ } catch (_) { routeRank = null; /* fs error → no server-side route finding; client panel + main audit still gate */ }
350
+ const permPath = permissionPathCheck.checkPermissionPaths({ changedFiles: committedFiles || [], builderRank });
351
+
352
+ if (routeRank && routeRank.hardFail) {
353
+ guardsTripped.push({ guard: 'route_rank', severity: 'blocker',
354
+ summary: 'server-side route-rank pre-pass hard-failed (an ungated write route exists) — a client PASS cannot override this (SR-9)' });
355
+ for (const f of routeRank.findings.filter((x) => x.severity === 'blocker')) {
356
+ extraIssues.push({ severity: 'blocker', file: f.file, summary: `[server route-rank] ${f.summary}`, worker: 'server-route-rank' });
357
+ }
358
+ if (clientPassed) downgradeTo = 'fail';
359
+ }
360
+ if (permPath && permPath.hardFail) {
361
+ guardsTripped.push({ guard: 'permission_path', severity: 'blocker',
362
+ summary: `server-side permission-path pre-pass hard-failed: rank '${builderRank}' is below Metic and the submitted committed_files touch protected machinery — a client PASS cannot override this (SR-9 / SEC #863)` });
363
+ for (const f of permPath.findings) {
364
+ extraIssues.push({ severity: 'blocker', file: f.file, summary: `[server permission-path] ${f.summary}`, worker: 'server-permission-path' });
365
+ }
366
+ if (clientPassed) downgradeTo = 'fail';
367
+ }
368
+
369
+ // --- Guard 1b: vacuous permission-path input (task 1002646 / report 1000021 G-1) ---
370
+ // The pre-pass above pairs the SERVER-known rank with CLIENT-supplied
371
+ // committed_files, so omitting the list (or sending []) made the sub-floor
372
+ // protected-path check pass VACUOUSLY — zero files, zero hits, hardFail:false —
373
+ // a trusted-rank check applied to an untrusted, omittable diff. The server
374
+ // still cannot re-derive the feature-branch diff at grade time (boundary note
375
+ // above; the task-1002645 published tip is recorded at ci-land, AFTER the
376
+ // grade), but it can refuse to treat "no input" as "no hits": a caller below
377
+ // the registry's lowest floor submitting NO file list makes the check
378
+ // UNPROVABLE → fail closed. Honest ships are unaffected (ship.js always sends
379
+ // the real committed list — task 878 — and a floor-clearing rank never enters
380
+ // this branch). The dishonest-LIST case (a sub-floor caller sending an
381
+ // innocuous list while touching protected paths) stays owned by gate-review CI
382
+ // + the post-push main audit, which read the REAL diff. Unknown rank keeps
383
+ // failing open at this layer (isBelowFloor → false) — the main audit reads the
384
+ // real author rank from the DB and owns that case, exactly as for the pre-pass.
385
+ const submittedFileCount = Array.isArray(committedFiles)
386
+ ? committedFiles.filter((f) => typeof f === 'string' && f.trim()).length
387
+ : 0;
388
+ if (permissionPathCheck.isBelowFloor(builderRank) && submittedFileCount === 0) {
389
+ // Resolve the floor label from the registry at runtime (the D-16/N-3
390
+ // stale-string rule) — never a hardcoded rank name.
391
+ const floorRank = Object.keys(permissionPathCheck.RANK_TIER)
392
+ .find((r) => permissionPathCheck.RANK_TIER[r] === permissionPathCheck.MIN_TIER) || 'the floor';
393
+ guardsTripped.push({ guard: 'permission_path_unprovable', severity: 'blocker',
394
+ summary: `rank '${builderRank}' is below the protected-path floor ('${floorRank}') and the grade submitted no committed_files — the permission-path check cannot run on an empty list, so a client PASS cannot stand (fail closed; task 1002646 / SR-9). Remedy: re-run the ship — ship.js submits the real committed file list; a genuinely file-less (DB-only) change needs a ${floorRank}+ builder to land it via /tasks/:id/confirm.` });
395
+ extraIssues.push({ severity: 'blocker', file: '(grade)', summary:
396
+ `[server permission-path] no committed_files submitted by a rank below the '${floorRank}' floor — the protected-path check is unprovable and fails closed (task 1002646)`, worker: 'server-permission-path' });
397
+ if (clientPassed) downgradeTo = 'fail';
398
+ }
399
+
400
+ // --- Guard 2: implausible client verdict (SR-9 / task 1002658, ADR 0164) ---
401
+ // Redesigned around what the submitted body itself can prove. The old form
402
+ // (raw issues.length === 0) was defeated by one fabricated `{}` AND fired on
403
+ // every honest unanimous zero-issue panel PASS (its only in-sample firings —
404
+ // eval 2-for-2, tasks 1002643 ×2 and 1002663 — were ALL false positives).
405
+ // Now: count only SUBSTANTIVE issues; a padded issues[] (entries but no
406
+ // substance) is the forgery signature and trips regardless of evidence; a
407
+ // genuinely empty issues[] trips only when the body ALSO lacks the full
408
+ // per-worker panel shape — a real panel that ran and unanimously found
409
+ // nothing is waived with an audit marker instead of a manual-confirm. The
410
+ // magnitude inputs remain client-supplied; the binding authority on the real
411
+ // diff is land-time (gate-review CI + post-push main audit) — ADR 0164.
412
+ const rawIssueCount = Array.isArray(base.issues) ? base.issues.length : 0;
413
+ const substantiveIssueCount = (Array.isArray(base.issues) ? base.issues : [])
414
+ .filter(isSubstantiveIssue).length;
415
+ const paddedIssues = rawIssueCount > 0 && substantiveIssueCount === 0;
416
+ const panelEvidence = hasPanelEvidence(base.signals);
417
+ const { files: changeFiles, lines: changeLines } = changeMagnitude({ committedFiles, diffStats: ds });
418
+ const nonTrivial = changeFiles > IMPLAUSIBLE_PASS_MAX_FILES || changeLines > IMPLAUSIBLE_PASS_MAX_LINES;
419
+ let implausiblePassWaived = null;
420
+ if (clientPassed && nonTrivial && substantiveIssueCount === 0 && (paddedIssues || !panelEvidence)) {
421
+ const shape = paddedIssues
422
+ ? `issues[] holds ${rawIssueCount} entr${rawIssueCount === 1 ? 'y' : 'ies'} with no substance (no known severity + summary) — the padded-issues forgery signature`
423
+ : 'empty issues[] with no per-worker panel evidence in signals';
424
+ guardsTripped.push({ guard: 'implausible_pass', severity: 'major',
425
+ summary: `client PASS on a non-trivial diff (${changeFiles} file(s), ${changeLines} line(s) > ${IMPLAUSIBLE_PASS_MAX_FILES}/${IMPLAUSIBLE_PASS_MAX_LINES}): ${shape} — not a credible default-fail panel verdict → manual-confirm (SR-9 / ADR 0164)` });
426
+ extraIssues.push({ severity: 'major', file: '(grade)', summary:
427
+ `[server plausibility] a passing grade with no substantive findings and no panel evidence on a ${changeFiles}-file / ${changeLines}-line change must be human-confirmed via /confirm`, worker: 'server-plausibility' });
428
+ if (downgradeTo !== 'fail') downgradeTo = 'concerns';
429
+ } else if (clientPassed && nonTrivial && rawIssueCount === 0 && panelEvidence) {
430
+ implausiblePassWaived = 'panel_evidence';
431
+ }
432
+
433
+ // --- Guard 3: bound the credit/achievement signals (SR-18) ---
434
+ // The net-negative "Subtractor" bonus + shipper badges key off client-supplied
435
+ // signals.diff_stats + committed_files the server can't re-derive. We can't
436
+ // make them server-authoritative without the feature-branch diff (residual,
437
+ // see report), but we CAN strip an OBVIOUSLY-FORGED net-negative claim:
438
+ // diff_stats that claims a net-negative (removed > added) while the submitted
439
+ // committed_files list is large is internally inconsistent with a real
440
+ // simplification, and is the cheap way to farm the Subtractor bonus. When the
441
+ // signals are internally implausible we NEUTRALIZE the net-negative reward by
442
+ // zeroing lines_removed in the forwarded diff_stats (so awardNetNegativeBonus
443
+ // sees removed<=added → bonus 0) WITHOUT altering the displayed/audited stats.
444
+ // Conservative: only fires on the inconsistent shape; an honest net-negative
445
+ // simplification (few files, removed>added) is untouched.
446
+ let sanitizedSignals = base.signals;
447
+ const claimsNetNegative =
448
+ ds && (Number(ds.lines_removed) || 0) > (Number(ds.lines_added) || 0);
449
+ const NET_NEGATIVE_MAX_FILES = 25; // a "simplification" touching >25 files is implausibly broad
450
+ if (claimsNetNegative && changeFiles > NET_NEGATIVE_MAX_FILES) {
451
+ guardsTripped.push({ guard: 'forged_net_negative', severity: 'minor',
452
+ summary: `net-negative diff_stats (removed ${ds.lines_removed} > added ${ds.lines_added}) across ${changeFiles} files is implausibly broad for a simplification — net-negative bonus neutralized (SR-18)` });
453
+ // Clone signals + zero the credit-driving field WITHOUT mutating the input
454
+ // or the displayed diff_stats audit value (we add a sibling marker instead).
455
+ sanitizedSignals = {
456
+ ...(base.signals || {}),
457
+ diff_stats: { ...ds, lines_removed: Number(ds.lines_added) || 0 },
458
+ diff_stats_reported: ds, // preserve what the client claimed for the audit trail
459
+ };
460
+ }
461
+
462
+ // No guard tripped (or only audit-level findings on a non-pass) → return the
463
+ // input with a passed-through audit marker so the trail shows guards ran.
464
+ if (downgradeTo == null && guardsTripped.length === 0 && sanitizedSignals === base.signals) {
465
+ return {
466
+ ...base,
467
+ signals: {
468
+ ...(base.signals || {}),
469
+ server_guard: {
470
+ applied: ['route_rank', 'permission_path', 'permission_path_unprovable', 'implausible_pass', 'forged_net_negative'],
471
+ ok: true,
472
+ tripped: [],
473
+ // task 1002658 / ADR 0164: the audit trail records WHY a zero-issue
474
+ // pass on a non-trivial diff stood (panel evidence), so /grade-audit
475
+ // can distinguish waived from never-evaluated.
476
+ ...(implausiblePassWaived ? { implausible_pass_waived: implausiblePassWaived } : {}),
477
+ },
478
+ },
479
+ };
480
+ }
481
+
482
+ // Build the downgraded result. Downgrade-only: if base wasn't passing we keep
483
+ // it not-passing; we never resurrect. Map the panel verdict onto the v3
484
+ // server gate the same way aggregatePanel does (fail → confidence 0; concerns
485
+ // → FALLTHROUGH_CONFIDENCE below the low-confidence cap; pass left as-is).
486
+ const newPassed = clientPassed && downgradeTo == null;
487
+ const forwardedConfidence =
488
+ downgradeTo === 'fail'
489
+ ? 0
490
+ : downgradeTo === 'concerns'
491
+ ? FALLTHROUGH_CONFIDENCE
492
+ : (base.signals?.confidence ?? null);
493
+
494
+ const guardNote = guardsTripped.length
495
+ ? `\n\nServer-authoritative guards (SR-9 / SR-18) ${downgradeTo ? `DOWNGRADED this grade to ${downgradeTo.toUpperCase()}` : 'flagged'}:\n`
496
+ + guardsTripped.map((g) => ` [${g.severity}] ${g.guard}: ${g.summary}`).join('\n')
497
+ : '';
498
+
499
+ return {
500
+ ...base,
501
+ passed: newPassed,
502
+ issues: [...(base.issues || []), ...extraIssues],
503
+ signals: {
504
+ ...sanitizedSignals,
505
+ // confidence drives the server-side gate alignment (validateAndScore on the
506
+ // body already ran; this is the authoritative post-hoc adjustment).
507
+ confidence: forwardedConfidence,
508
+ server_guard: {
509
+ applied: ['route_rank', 'permission_path', 'permission_path_unprovable', 'implausible_pass', 'forged_net_negative'],
510
+ ok: guardsTripped.length === 0,
511
+ downgraded_to: downgradeTo,
512
+ client_passed: clientPassed,
513
+ tripped: guardsTripped,
514
+ // task 1002614 (map P-2): a permission_path trip is an AUTHORIZATION
515
+ // refusal of the builder's rank over protected paths — not a verdict on
516
+ // the code. Named here so clients render it as a wall, not a quality
517
+ // FAIL the builder should fix-and-retry (the envelope used to teach
518
+ // exactly that wrong lesson). route_rank stays a code finding.
519
+ // task 1002646: permission_path_unprovable is the same wall — the rank
520
+ // floor refusing an unverifiable diff — so it renders the same way.
521
+ authorization_refusal: guardsTripped.some((g) => g.guard === 'permission_path' || g.guard === 'permission_path_unprovable'),
522
+ ...(implausiblePassWaived ? { implausible_pass_waived: implausiblePassWaived } : {}),
523
+ },
524
+ },
525
+ notes_md: `${base.notes_md || ''}${guardNote}`,
526
+ };
527
+ }
528
+
529
+ // ---------- v3: multi-shot aggregation ----------
530
+
531
+ // Collapses N per-shot grade-results into the final v3 grade-result.
532
+ // Pass requires unanimous PASS on all shots; anything else (any FAIL, any
533
+ // error, any disagreement) → not passed. Issues are deduped across shots
534
+ // by (file, summary). Displayed rubric_scores use the median across shots
535
+ // so the trend dashboard stays one-number-per-task.
536
+ function aggregateShots(shotResults, meta = {}) {
537
+ const N = shotResults.length;
538
+ const verdicts = shotResults.map((s) => ({
539
+ passed: s.passed,
540
+ ff: s.rubric_scores?.functional_fidelity ?? null,
541
+ cq: s.rubric_scores?.code_quality ?? null,
542
+ economy: s.rubric_scores?.economy ?? null,
543
+ cq_effective: s.rubric_scores?.code_quality_effective ?? null,
544
+ confidence: s.signals?.confidence ?? null,
545
+ model: s.signals?.model ?? null,
546
+ run_ms: s.signals?.run_ms ?? null,
547
+ shot_index: s.signals?.shot_index ?? null,
548
+ notes_md: s.notes_md,
549
+ issues: s.issues || [],
550
+ error:
551
+ s.signals?.parse_error
552
+ ? 'parse_error'
553
+ : s.signals?.schema_error
554
+ ? 'schema_error'
555
+ : s.signals?.subprocess_error || null,
556
+ }));
557
+
558
+ const ff = median(verdicts.map((v) => v.ff));
559
+ const cq = median(verdicts.map((v) => v.cq));
560
+ const economy = median(verdicts.map((v) => v.economy));
561
+ const cqEffective = median(verdicts.map((v) => v.cq_effective));
562
+ const blended = ff != null && cq != null ? r1((ff + cq) / 2) : 0;
563
+ // Top-level aggregated confidence = median of per-shot confidences. Without
564
+ // this, ship.js forwards a null top-level confidence to /tasks/:id/grade,
565
+ // and the server's validateAndScore (re-running on the POST body) hits the
566
+ // Number(null)=0 trap → FAIL even though every shot independently passed.
567
+ // Caught on the MAS Phase 1 self-ship (#395).
568
+ //
569
+ // Computed UNROUNDED (no r1()) — confidence sits near a 0.4 gate cap so
570
+ // rounding to one decimal could nudge a borderline value across the cap
571
+ // in either direction. Per-shot confidences are already 0-1 floats.
572
+ const confVals = verdicts
573
+ .map((v) => v.confidence)
574
+ .filter((n) => Number.isFinite(n))
575
+ .sort((a, b) => a - b);
576
+ const confidenceMedian = confVals.length === 0
577
+ ? null
578
+ : confVals.length % 2
579
+ ? confVals[(confVals.length - 1) / 2]
580
+ : (confVals[confVals.length / 2 - 1] + confVals[confVals.length / 2]) / 2;
581
+
582
+ // Dedupe issues across shots by (file, summary). Keep first-seen severity.
583
+ const seen = new Set();
584
+ const issues = [];
585
+ for (const v of verdicts) {
586
+ for (const it of v.issues) {
587
+ if (!it || typeof it !== 'object') continue;
588
+ const key = `${it.file ?? ''}::${(it.summary ?? '').trim()}`;
589
+ if (seen.has(key)) continue;
590
+ seen.add(key);
591
+ issues.push(it);
592
+ }
593
+ }
594
+
595
+ const passCount = verdicts.filter((v) => v.passed === true).length;
596
+ const failCount = verdicts.filter((v) => v.passed === false && !v.error).length;
597
+ const errorCount = verdicts.filter((v) => v.error).length;
598
+ const allPassed = N > 0 && passCount === N;
599
+
600
+ let verdict;
601
+ let passed;
602
+ let aggregationKind;
603
+ if (errorCount > 0) {
604
+ passed = false;
605
+ verdict = `FAIL — ${errorCount}/${N} shot(s) errored`;
606
+ aggregationKind = 'errored';
607
+ } else if (allPassed) {
608
+ passed = true;
609
+ verdict = `PASS — ${N}/${N} shots unanimous`;
610
+ aggregationKind = 'unanimous_pass';
611
+ } else if (passCount === 0) {
612
+ passed = false;
613
+ verdict = `FAIL — ${failCount}/${N} shots`;
614
+ aggregationKind = 'unanimous_fail';
615
+ } else {
616
+ passed = false;
617
+ verdict = `FAIL — ${passCount} pass, ${failCount} fail (no unanimous pass)`;
618
+ aggregationKind = 'disagreement';
619
+ }
620
+
621
+ const lines = [];
622
+ lines.push(`Subagent grader ${VERSION} (multi-shot, ${N}× shots) — ${verdict}`);
623
+ lines.push(` functional_fidelity (median): ${ff ?? '?'}/10`);
624
+ lines.push(` code_quality (median): ${cq ?? '?'}/10`);
625
+ if (economy != null) {
626
+ lines.push(` economy (advisory, median): ${economy}/10`);
627
+ }
628
+ lines.push(` blended (median): ${blended}/10 (threshold ${THRESHOLD}, both axes must clear on EVERY shot)`);
629
+ lines.push('');
630
+ lines.push('Per-shot:');
631
+ for (let i = 0; i < verdicts.length; i++) {
632
+ const v = verdicts[i];
633
+ const status = v.error ? 'ERROR' : v.passed === true ? 'PASS' : 'FAIL';
634
+ const ffStr = v.ff != null ? `${v.ff}` : '?';
635
+ const cqStr = v.cq != null ? `${v.cq}` : '?';
636
+ const confStr = v.confidence != null ? ` conf=${v.confidence}` : '';
637
+ const errStr = v.error ? ` (${v.error})` : '';
638
+ lines.push(` shot[${i}] ${status} ff=${ffStr} cq=${cqStr}${confStr} ${v.model ?? '?'}${errStr}`);
639
+ }
640
+ if (issues.length > 0) {
641
+ lines.push('');
642
+ lines.push(`Issues (${issues.length} unique across shots):`);
643
+ for (const it of issues.slice(0, 20)) {
644
+ const sev = String(it.severity || '?').padEnd(7);
645
+ const file = String(it.file || '?');
646
+ const summary = String(it.summary || '').replace(/\s+/g, ' ').trim();
647
+ lines.push(` [${sev}] ${file} — ${summary}`);
648
+ }
649
+ if (issues.length > 20) {
650
+ lines.push(` ... ${issues.length - 20} more (full set in signals.shots[].issues)`);
651
+ }
652
+ }
653
+
654
+ // #486 (ADR 0028 path 1): sum each shot's own subprocess cost (envelope
655
+ // total_cost_usd) so the trivial-diff path reports panel_cost_usd the same way
656
+ // the full panel does. The single-shot trivial path is the common case here.
657
+ const panelCostUsd = shotResults.reduce((s, r) => s + (Number(r.cost_usd) || 0), 0);
658
+
659
+ return {
660
+ score: blended,
661
+ passed,
662
+ threshold: THRESHOLD,
663
+ grader_kind: 'subagent-multishot',
664
+ grader_version: VERSION,
665
+ panel_cost_usd: panelCostUsd, // #486: panel's own API spend (USD)
666
+ rubric_scores: {
667
+ functional_fidelity: ff,
668
+ code_quality: cq,
669
+ economy,
670
+ code_quality_effective: cqEffective,
671
+ },
672
+ signals: {
673
+ grader_kind: 'subagent-multishot',
674
+ grader_version: VERSION,
675
+ threshold: THRESHOLD,
676
+ // Top-level confidence = median across shots. ship.js forwards this to
677
+ // POST /tasks/:id/grade so the server-side re-validation has a real
678
+ // number rather than null (see Number(null)=0 fix in validateAndScore).
679
+ confidence: confidenceMedian,
680
+ shots: verdicts,
681
+ shot_count: N,
682
+ pass_count: passCount,
683
+ fail_count: failCount,
684
+ error_count: errorCount,
685
+ shot_aggregation: aggregationKind,
686
+ ...meta,
687
+ },
688
+ notes_md: lines.join('\n'),
689
+ issues,
690
+ };
691
+ }
692
+
693
+ // ---------- v3: subagent subprocess invoker ----------
694
+
695
+ // Unchanged from v2 — spawns `claude -p --print --model <model> --output-
696
+ // format json` with stdin = prompt. The v3 caller varies `model` per shot
697
+ // via opts.model (set by gradeSubagent below).
698
+
699
+ module.exports = {
700
+ aggregateShots,
701
+ changeMagnitude,
702
+ hasPanelEvidence,
703
+ isSubstantiveIssue,
704
+ serverSideGuards,
705
+ validateAndScore,
706
+ };