@bongos/core 1.19.331 → 1.19.333
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 +163 -43
- package/docs/api/openapi.json +2 -2
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +202 -202
- package/docs/copy-registry.json +384 -384
- package/modules/grading/grader-config.js +98 -0
- package/modules/grading/grader-model.js +150 -0
- package/modules/grading/grader-prompt.js +139 -0
- package/modules/grading/grader-score.js +706 -0
- package/modules/grading/grader-subagent.js +328 -0
- package/modules/grading/grader.js +5 -1287
- package/modules/hall-ui/public/builders.js +19 -1182
- package/modules/hall-ui/public/goals-page.js +11 -997
- package/modules/hall-ui/public/goals-render.js +1032 -0
- package/modules/hall-ui/public/goals.html +4 -0
- package/modules/hall-ui/public/hall-render.js +1211 -0
- package/modules/hall-ui/public/index.html +6 -0
- package/modules/lifecycle/routes/goal-route-authz.js +400 -0
- package/modules/lifecycle/routes/goals.js +1 -367
- package/modules/lifecycle/routes/task-route-helpers.js +219 -0
- package/modules/lifecycle/routes/task-write-routes.js +542 -0
- package/modules/lifecycle/routes/tasks.js +3 -696
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +4 -0
- package/public-landing/projects.html +103 -23
- package/scripts/gds/box-infra.js +280 -0
- package/scripts/gds/box.js +1 -245
- package/scripts/gds/fitness-checks-identity.js +320 -0
- package/scripts/gds/fitness-checks-vocabulary.js +230 -0
- package/scripts/gds/fitness-checks-write-validation.js +144 -0
- package/scripts/gds/fitness-lib.js +82 -0
- package/scripts/gds/fitness.js +55 -668
- package/scripts/gds/provision-config.js +204 -0
- package/scripts/gds/provision-net.js +221 -0
- package/scripts/gds/provision-repo.js +849 -0
- package/scripts/gds/provision-units.js +262 -0
- package/scripts/gds/provision.js +4 -1395
- package/src/bongos/auth-admission.js +264 -0
- package/src/bongos/auth-config.js +151 -0
- package/src/bongos/auth-discord.js +198 -0
- package/src/bongos/auth-github.js +336 -0
- package/src/bongos/auth-idp.js +330 -0
- package/src/bongos/auth.js +5 -1100
- package/src/module-api.js +5 -1
- package/tests/auth_admission_gate.mjs +3 -1
- package/tests/est_advisory.mjs +3 -1
- package/tests/goal_advisory.mjs +3 -2
- package/tests/goal_criterion_confirm_ui.mjs +6 -0
- package/tests/goal_subtitle.mjs +5 -2
- package/tests/goals_card_view.mjs +10 -3
- package/tests/grader_generated_provenance.mjs +3 -1
- package/tests/hall_palette.mjs +3 -1
- package/tests/helpers.mjs +58 -25
- package/tests/lib_sh_resolution.mjs +3 -1
- package/tests/membership_kind_lockstep.mjs +3 -1
- package/tests/migration_allocation.mjs +5 -2
- package/tests/patch_gate_ordering.mjs +3 -1
- package/tests/projects_hub_pre_uat.mjs +53 -0
- package/tests/rank_tier_single_source.mjs +16 -3
- 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
|
+
};
|