@bongos/core 1.19.1036 → 1.19.1038

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 (42) hide show
  1. package/.bongos-core.json +62 -52
  2. package/.claude/skills/grade-recover/SKILL.md +1 -1
  3. package/docs/adr/0089-modular-multi-model-adversarial-grader.md +2 -0
  4. package/docs/adr/0302-the-third-identical-failure-stops-the-session-and-asks.md +3 -2
  5. package/docs/adr/0329-the-rendered-fit-check-is-a-kit-floor-and-an-advisory-ship-check-row.md +2 -2
  6. package/docs/adr/README.md +1 -1
  7. package/docs/module-api-changelog.md +4 -0
  8. package/modules/economy/credits.js +1 -1
  9. package/modules/government/protected-surfaces.json +2 -0
  10. package/modules/grading/grader-config.js +3 -12
  11. package/modules/grading/grader-model.js +50 -5
  12. package/modules/grading/grader-no-artifact.js +227 -2
  13. package/modules/grading/grader-prompt.js +1 -1
  14. package/modules/grading/grader-providers.neutral.json +4 -0
  15. package/modules/grading/grader-rubric.json +6 -86
  16. package/modules/grading/grader-score.js +1 -169
  17. package/modules/grading/grader-subagent.js +18 -52
  18. package/modules/grading/grader.js +26 -211
  19. package/modules/grading/model-provider.js +257 -0
  20. package/modules/lifecycle/db-ship.js +3 -3
  21. package/package-lock.json +2 -2
  22. package/package.json +1 -1
  23. package/release-notes.json +16 -0
  24. package/scripts/gds/claim.js +2 -1
  25. package/scripts/gds/grade-smoke.js +3 -3
  26. package/scripts/gds/render-check.js +1 -1
  27. package/scripts/gds/ship-check.js +1 -1
  28. package/scripts/gds/ship-flow.js +1 -1
  29. package/scripts/gds/ship-grade-steps.js +2 -2
  30. package/scripts/gds/visual-review-nudge.js +2 -1
  31. package/src/module-api.js +1 -1
  32. package/tests/adr_0162_gate_citation.mjs +167 -0
  33. package/tests/government_protected_surfaces.mjs +14 -0
  34. package/tests/grade_model_provider.mjs +217 -0
  35. package/tests/grader.mjs +48 -126
  36. package/tests/grader_unbacked_score.mjs +1 -1
  37. package/tests/regrade_actual_minutes.mjs +18 -17
  38. package/tests/render_check.mjs +1 -1
  39. package/tests/skill_grade_audit.mjs +3 -3
  40. package/tests/skill_grader_health.mjs +2 -2
  41. package/scripts/gds/audit-ships.js +0 -319
  42. package/tests/audit_ships_git_argv.mjs +0 -173
@@ -0,0 +1,217 @@
1
+ // tests/grade_model_provider.mjs — the ModelProvider port + the openai-compatible
2
+ // adapter (task 1002840, ADR 0089 §3).
3
+ //
4
+ // Pins: (1) every model id in use today still runs on the Claude spawn, with the
5
+ // identical argv; (2) the openai-compatible adapter against a LOCAL STUB server —
6
+ // request shape, envelope, cost, and every failure mapped to the error codes the
7
+ // worker runner already understands — with no real key and no network; (3) a key
8
+ // that is missing sends nothing, and a key never leaks into an error; (4) the
9
+ // config loader fails to vanilla; (5) the judge table reproduces the old if/else
10
+ // and refuses a same-family or non-Claude judge (ADR 0024 / the Claude floor).
11
+ //
12
+ // Run: node --test tests/grade_model_provider.mjs
13
+
14
+ import { test } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import http from 'node:http';
20
+ import { createRequire } from 'node:module';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const subagent = require('../modules/grading/grader-subagent.js');
24
+ const mp = require('../modules/grading/model-provider.js');
25
+ const model = require('../modules/grading/grader-model.js');
26
+ const workers = require('../modules/grading/grader-workers');
27
+
28
+ // A local OpenAI-shaped server. `reply(req, body)` returns { status, json|text, delayMs }.
29
+ async function stubServer(reply) {
30
+ const seen = [];
31
+ const server = http.createServer((req, res) => {
32
+ let raw = '';
33
+ req.on('data', (d) => { raw += d; });
34
+ req.on('end', async () => {
35
+ const body = raw ? JSON.parse(raw) : null;
36
+ seen.push({ method: req.method, url: req.url, headers: req.headers, body });
37
+ const r = reply(req, body) || {};
38
+ if (r.delayMs) await new Promise((ok) => setTimeout(ok, r.delayMs));
39
+ res.writeHead(r.status || 200, { 'content-type': 'application/json' });
40
+ res.end(r.text != null ? r.text : JSON.stringify(r.json));
41
+ });
42
+ });
43
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
44
+ const { port } = server.address();
45
+ return { seen, base: `http://127.0.0.1:${port}/v1`, close: () => new Promise((ok) => server.close(ok)) };
46
+ }
47
+
48
+ const KEY = 'test-key-not-a-real-secret-0123456789';
49
+ function provider(base, extra = {}, env = { STUB_LLM_KEY: KEY }) {
50
+ const v = mp.validateProviderDef('stub', { kind: 'openai-compatible', base_url: base, api_key_env: 'STUB_LLM_KEY', ...extra });
51
+ assert.ok(v.def, v.error);
52
+ return mp.createOpenAICompatibleProvider(v.def, { env });
53
+ }
54
+ const completion = (content, usage = { prompt_tokens: 1000, completion_tokens: 200 }) => ({ json: { choices: [{ message: { role: 'assistant', content } }], usage } });
55
+
56
+ test('parseModelRef: every id in use today is a Claude model; only a REGISTERED prefix routes elsewhere', () => {
57
+ for (const id of ['claude-sonnet-5', 'claude-opus-5', 'claude-fable-5', '', 'x:y']) {
58
+ assert.deepEqual(mp.parseModelRef(id), { provider: 'anthropic-cli', model: id });
59
+ }
60
+ mp.registerModelProvider({ name: 'test-ollama', kind: 'openai-compatible', run: async () => ({}) });
61
+ assert.deepEqual(mp.parseModelRef('test-ollama:qwen2.5-coder:32b'), { provider: 'test-ollama', model: 'qwen2.5-coder:32b' },
62
+ 'split at the FIRST colon only — a colon inside the model id survives');
63
+ assert.deepEqual(mp.parseModelRef('anthropic-cli:claude-sonnet-5'), { provider: 'anthropic-cli', model: 'anthropic-cli:claude-sonnet-5' },
64
+ 'the default is never a prefix');
65
+ assert.ok(mp.listModelProviders().includes('anthropic-cli'));
66
+ });
67
+
68
+ test('the Claude spawn is the default provider, with the identical argv', { skip: process.platform === 'win32' && 'the fake CLI is a shebang script' }, async () => {
69
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'grade-provider-'));
70
+ const bin = path.join(dir, 'claude');
71
+ fs.writeFileSync(bin, '#!/usr/bin/env node\nprocess.stdin.resume(); process.stdin.on("end", () => process.stdout.write(JSON.stringify({ type: "result", result: JSON.stringify(process.argv.slice(2)), total_cost_usd: 0.5 })));\n');
72
+ fs.chmodSync(bin, 0o755);
73
+ try {
74
+ const r = await subagent.runSubagent({ prompt: 'hi', opts: { claudeBin: bin, model: 'claude-sonnet-5', cwd: dir } });
75
+ const argv = JSON.parse(subagent.extractAssistantResult(r.stdout));
76
+ const perm = typeof process.getuid === 'function' && process.getuid() === 0 ? 'acceptEdits' : 'bypassPermissions';
77
+ assert.deepEqual(argv, ['-p', '--print', '--model', 'claude-sonnet-5', '--output-format', 'json', '--tools', 'Read', '--disable-slash-commands', '--permission-mode', perm]);
78
+ assert.equal(r.cost_usd, 0.5);
79
+ const d = await subagent.runSubagent({ prompt: 'hi', opts: { claudeBin: bin, cwd: dir } });
80
+ assert.equal(JSON.parse(subagent.extractAssistantResult(d.stdout))[3], 'claude-sonnet-5', 'no model → the default grader model, as before');
81
+ } finally {
82
+ fs.rmSync(dir, { recursive: true, force: true });
83
+ }
84
+ });
85
+
86
+ test('openai-compatible: request shape, envelope and cost — against a local stub', async () => {
87
+ const s = await stubServer(() => completion('{"code_quality": 8}'));
88
+ try {
89
+ const p = provider(s.base, { price_per_mtok: { 'deepseek-x': { input: 0.25, output: 1 } } });
90
+ const r = await p.run({ prompt: 'PROMPT TEXT', opts: { model: 'deepseek-x', timeoutMs: 5000 } });
91
+ assert.equal(s.seen.length, 1);
92
+ assert.equal(s.seen[0].method, 'POST');
93
+ assert.equal(s.seen[0].url, '/v1/chat/completions');
94
+ assert.equal(s.seen[0].headers.authorization, `Bearer ${KEY}`);
95
+ assert.deepEqual(s.seen[0].body, { model: 'deepseek-x', messages: [{ role: 'user', content: 'PROMPT TEXT' }] });
96
+ // The same envelope contract the Claude spawn honours, so nothing downstream changes.
97
+ assert.equal(subagent.extractAssistantResult(r.stdout), '{"code_quality": 8}');
98
+ const cost = (1000 * 0.25 + 200 * 1) / 1e6;
99
+ assert.equal(subagent.extractCostUsd(r.stdout), cost);
100
+ assert.equal(r.cost_usd, cost);
101
+ assert.equal(r.exit_code, 0);
102
+ const unpriced = await p.run({ prompt: 'x', opts: { model: 'other-model' } });
103
+ assert.equal(unpriced.cost_usd, null, 'no declared price → no cost number, never a guess');
104
+ assert.equal(subagent.extractCostUsd(unpriced.stdout), null);
105
+ } finally { await s.close(); }
106
+ });
107
+
108
+ test('openai-compatible: failures map to the codes the worker runner already retries or reports', async () => {
109
+ const s = await stubServer((req, body) => {
110
+ if (body.model === 'http-500') return { status: 500, text: `upstream says ${KEY} is invalid` };
111
+ if (body.model === 'slow') return { delayMs: 300, ...completion('late') };
112
+ if (body.model === 'garbage') return { text: 'not json at all' };
113
+ return completion('ok');
114
+ });
115
+ try {
116
+ const p = provider(s.base);
117
+ await assert.rejects(p.run({ prompt: 'x', opts: { model: 'http-500' } }), (e) => {
118
+ assert.equal(e.code, 'SUBAGENT_NONZERO_EXIT');
119
+ assert.equal(e.exit_code, 500);
120
+ assert.ok(!e.stderr.includes(KEY) && e.stderr.includes('[redacted]'), 'the key is scrubbed from what a grade row stores');
121
+ return true;
122
+ });
123
+ await assert.rejects(p.run({ prompt: 'x', opts: { model: 'slow', timeoutMs: 50 } }), (e) => e.code === 'SUBAGENT_TIMEOUT');
124
+ const g = await p.run({ prompt: 'x', opts: { model: 'garbage' } });
125
+ assert.equal(subagent.extractAssistantResult(g.stdout), '', 'an unparseable 200 is an empty result → the worker parse_error path, retried once');
126
+ } finally { await s.close(); }
127
+ const dead = provider(s.base);
128
+ await assert.rejects(dead.run({ prompt: 'x', opts: { model: 'm' } }), (e) => e.code === 'SUBAGENT_SPAWN_FAILED', 'a closed port is a spawn failure');
129
+ });
130
+
131
+ test('a missing key sends NOTHING anywhere', async () => {
132
+ const s = await stubServer(() => completion('ok'));
133
+ try {
134
+ const p = provider(s.base, {}, {});
135
+ await assert.rejects(p.run({ prompt: 'the diff', opts: { model: 'm' } }), (e) => e.code === 'SUBAGENT_SPAWN_FAILED' && /set STUB_LLM_KEY/.test(e.message));
136
+ assert.equal(s.seen.length, 0);
137
+ } finally { await s.close(); }
138
+ });
139
+
140
+ test('an open-model judge runs through the SAME worker runner and verdict code', async () => {
141
+ const s = await stubServer(() => completion(JSON.stringify({ code_quality: 8, economy: 8, confidence: 0.8, issues: [], notes_md: 'fine' })));
142
+ try {
143
+ mp.registerModelProvider({ ...provider(s.base, { price_per_mtok: { 'q-coder': { input: 1, output: 1 } } }), name: 'test-stub' });
144
+ const r = await workers.runWorker(workers.quality, {
145
+ task: { id: 1, title: 't', description: 'd' }, valueSummary: 'v', diff: 'diff', changedFiles: [], diffStats: null,
146
+ model: 'test-stub:q-coder', runOpts: {}, runSubagentFn: subagent.runSubagent, extractAssistantResultFn: subagent.extractAssistantResult,
147
+ });
148
+ assert.equal(r.verdict, 'pass');
149
+ assert.equal(r.scores.code_quality, 8);
150
+ assert.equal(r.model, 'test-stub:q-coder', 'the record names the provider');
151
+ assert.equal(r.cost_usd, 1200 / 1e6);
152
+ assert.equal(s.seen[0].body.model, 'q-coder', 'the provider receives its own model id');
153
+ } finally { await s.close(); }
154
+ });
155
+
156
+ test('provider config: validated, and a bad instance file fails to vanilla', () => {
157
+ const bad = (name, def) => mp.validateProviderDef(name, def).error;
158
+ assert.match(bad('deepinfra', { kind: 'openai-compatible', base_url: 'http://api.example.com/v1', api_key_env: 'K_KEY' }), /https/);
159
+ assert.match(bad('deepinfra', { kind: 'openai-compatible', base_url: 'https://api.example.com/v1', api_key_env: 'sk-live-abc' }), /env var NAME/);
160
+ assert.match(bad('deepinfra', { kind: 'openai-compatible', base_url: 'https://api.example.com/v1' }), /needs api_key_env/);
161
+ assert.match(bad('anthropic-cli', { kind: 'openai-compatible', base_url: 'https://x.example/v1', api_key_env: 'K' }), /must match|not be/);
162
+ assert.match(bad('x-prov', { kind: 'grpc', base_url: 'https://x.example/v1', api_key_env: 'KK' }), /kind/);
163
+ assert.equal(bad('local-ollama', { kind: 'openai-compatible', base_url: 'http://localhost:11434/v1' }), undefined, 'keyless http is allowed to this machine only');
164
+
165
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'grade-provider-cfg-'));
166
+ try {
167
+ const neutral = path.join(gradingDir(), 'grader-providers.neutral.json');
168
+ assert.deepEqual(mp.loadProviderConfig({ instancePath: path.join(dir, 'absent.json'), neutralPath: neutral }).defs, [],
169
+ 'the shipped neutral starter declares no provider — a vanilla instance is Claude-only');
170
+ const broken = path.join(dir, 'broken.json');
171
+ fs.writeFileSync(broken, '{ not json');
172
+ assert.equal(mp.loadProviderConfig({ instancePath: broken, neutralPath: neutral }).source, neutral);
173
+ const mixed = path.join(dir, 'mixed.json');
174
+ fs.writeFileSync(mixed, JSON.stringify({ providers: {
175
+ deepinfra: { kind: 'openai-compatible', base_url: 'https://api.deepinfra.com/v1/openai', api_key_env: 'DEEPINFRA_API_KEY' },
176
+ leaky: { kind: 'openai-compatible', base_url: 'http://api.example.com/v1', api_key_env: 'LEAKY_KEY' },
177
+ } }));
178
+ const cfg = mp.loadProviderConfig({ instancePath: mixed, neutralPath: neutral });
179
+ assert.deepEqual(cfg.defs.map((d) => d.name), ['deepinfra']);
180
+ assert.equal(cfg.refused.length, 1, 'one bad entry is refused by name; the rest load');
181
+ } finally {
182
+ fs.rmSync(dir, { recursive: true, force: true });
183
+ }
184
+ });
185
+
186
+ function gradingDir() {
187
+ return path.dirname(require.resolve('../modules/grading/model-provider.js'));
188
+ }
189
+
190
+ test('the judge table reproduces the old if/else, and refuses a same-family or non-Claude judge', () => {
191
+ const oldIfElse = (bm) => {
192
+ const s = bm.toLowerCase();
193
+ if (s.includes('fable') || s.includes('mythos')) return 'claude-sonnet-5';
194
+ if (s.includes('opus')) return 'claude-sonnet-5';
195
+ if (s.includes('sonnet')) return 'claude-opus-5';
196
+ if (s.includes('haiku')) return 'claude-opus-5';
197
+ return null;
198
+ };
199
+ const tokens = ['fable', 'mythos', 'opus', 'sonnet', 'haiku'];
200
+ const ids = ['claude-fable-5', 'claude-opus-5-5', 'claude-sonnet-5', 'claude-haiku-4-5', 'Claude-OPUS-4-8'];
201
+ for (const a of tokens) for (const b of tokens) ids.push(`claude-${a}-${b}`);
202
+ for (const id of ids) assert.equal(model.selectGraderModel({ builderModel: id }), oldIfElse(id), id);
203
+ assert.deepEqual(model.JUDGE_TABLE, model.BUILTIN_JUDGE_TABLE, 'the rubric table is exactly the built-in fallback');
204
+
205
+ const logged = [];
206
+ const orig = console.error;
207
+ console.error = (...a) => { logged.push(a.join(' ')); };
208
+ try {
209
+ assert.equal(model.selectGraderModel({ builderModel: 'gpt-9' }), 'claude-sonnet-5', 'an unknown builder warns and grades on the default — never throws (task 1002659)');
210
+ } finally { console.error = orig; }
211
+ assert.match(logged.join('\n'), /unrecognized builder model/);
212
+
213
+ assert.match(model.judgeTableProblems([{ builder_families: ['opus'], judge_model: 'claude-opus-5' }]).join(), /family it would grade/);
214
+ assert.match(model.judgeTableProblems([{ builder_families: ['opus'], judge_model: 'deepinfra:deepseek-v3' }]).join(), /plain Claude model id/);
215
+ assert.match(model.judgeTableProblems([]).join(), /non-empty/);
216
+ assert.deepEqual(model.judgeTableProblems(model.BUILTIN_JUDGE_TABLE), []);
217
+ });
package/tests/grader.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  // tests/grader.mjs
2
2
  //
3
- // Grader tests. Covers both:
4
- // - the v1 deterministic process-audit grader (kept for audit-ships.js)
5
- // - the v2 subagent quality grader (the actual credits gate from V3.R18 #209)
3
+ // Grader tests: the subagent quality grader (the credits gate since V3.R18 #209).
4
+ // The v1 deterministic process-audit grader was retired in task 1002842; the
5
+ // export-surface test below pins that it stays gone.
6
6
  //
7
7
  // The v2 subagent itself is a real-network call, so the tests stop at the
8
8
  // validator + prompt-builder boundary; the network-touching end-to-end check
@@ -48,127 +48,6 @@ async function testAsync(name, fn) {
48
48
  }
49
49
  }
50
50
 
51
- // ========================================================================
52
- // v1 — deterministic grader (legacy, used only by audit-ships.js)
53
- // ========================================================================
54
-
55
- test('v1 scoreEstActualBand: ratio inside [0.5, 2.0] → 10', () => {
56
- assert.equal(grader.scoreEstActualBand(60, 60), 10);
57
- assert.equal(grader.scoreEstActualBand(30, 60), 10);
58
- assert.equal(grader.scoreEstActualBand(120, 60), 10);
59
- });
60
-
61
- test('v1 scoreEstActualBand: ratio outside band drops linearly', () => {
62
- assert.equal(grader.scoreEstActualBand(18, 60), 5);
63
- assert.equal(grader.scoreEstActualBand(360, 60), 5);
64
- });
65
-
66
- test('v1 scoreEstActualBand: extreme ratios → 0', () => {
67
- assert.equal(grader.scoreEstActualBand(6, 60), 0);
68
- assert.equal(grader.scoreEstActualBand(600, 60), 0);
69
- });
70
-
71
- test('v1 scoreEstActualBand: missing est → 6, missing actual → 5', () => {
72
- assert.equal(grader.scoreEstActualBand(60, null), 6);
73
- assert.equal(grader.scoreEstActualBand(null, 60), 5);
74
- });
75
-
76
- test('v1 scoreValueSummary: maps length + verb', () => {
77
- assert.equal(grader.scoreValueSummary(''), 0);
78
- assert.equal(grader.scoreValueSummary(null), 0);
79
- assert.equal(grader.scoreValueSummary('short note'), 5);
80
- assert.equal(grader.scoreValueSummary('a moderately worded summary that lands above forty characters'), 8);
81
- const s = 'wire the auto-grader into the completed→confirmed transition so every task gets a quality mark before its credits are paid out';
82
- assert.ok(s.length > 120);
83
- assert.equal(grader.scoreValueSummary(s), 10);
84
- });
85
-
86
- test('v1 scoreDiffHygiene: phantom ship → 0, normal → 10, too-broad → 5', () => {
87
- assert.equal(grader.scoreDiffHygiene({ files_changed: 0, lines_added: 0, lines_removed: 0 }), 0);
88
- assert.equal(grader.scoreDiffHygiene({ files_changed: 5, lines_added: 200, lines_removed: 30 }), 10);
89
- assert.equal(grader.scoreDiffHygiene({ files_changed: 80, lines_added: 1000, lines_removed: 200 }), 5);
90
- });
91
-
92
- test('v1 scoreScopingSignal: maps each level', () => {
93
- assert.equal(grader.scoreScopingSignal(0), 2);
94
- assert.equal(grader.scoreScopingSignal(1), 4);
95
- assert.equal(grader.scoreScopingSignal(2), 6);
96
- assert.equal(grader.scoreScopingSignal(3), 7);
97
- assert.equal(grader.scoreScopingSignal(4), 8);
98
- assert.equal(grader.scoreScopingSignal(5), 10);
99
- assert.equal(grader.scoreScopingSignal(null), 6);
100
- });
101
-
102
- test('v1 scoreLocNetNegative', () => {
103
- assert.equal(grader.scoreLocNetNegative({ lines_added: 20, lines_removed: 100 }), 10);
104
- assert.equal(grader.scoreLocNetNegative({ lines_added: 100, lines_removed: 20 }), 0);
105
- });
106
-
107
- test('v1 scoreLearningRow', () => {
108
- assert.equal(grader.scoreLearningRow({ body_md: 'x'.repeat(200) }), 10);
109
- assert.equal(grader.scoreLearningRow(null), 0);
110
- });
111
-
112
- test('v1 scoreArtRubricAvg clamps + nulls', () => {
113
- assert.equal(grader.scoreArtRubricAvg(8.7), 8.7);
114
- assert.equal(grader.scoreArtRubricAvg(15), 10);
115
- assert.equal(grader.scoreArtRubricAvg(-1), 0);
116
- assert.equal(grader.scoreArtRubricAvg(NaN), null);
117
- });
118
-
119
- test('v1 blendWeightsForKind: feature renorms to 1.0', () => {
120
- const w = grader.blendWeightsForKind('feature');
121
- const total = Object.values(w).reduce((a, b) => a + b, 0);
122
- assert.ok(Math.abs(total - 1.0) < 1e-6, `total=${total}, expected 1.0`);
123
- });
124
-
125
- test('v1 blendWeightsForKind: cleanup replaces diff_hygiene', () => {
126
- const w = grader.blendWeightsForKind('cleanup');
127
- assert.ok('loc_net_negative' in w);
128
- assert.ok(!('diff_hygiene' in w));
129
- });
130
-
131
- test('v1 gradeDeterministic: well-shipped feature passes', () => {
132
- const result = grader.gradeDeterministic({
133
- task: { id: 99, kind: 'feature', est_minutes: 240, actual_minutes: 200, scoping_confidence: 4 },
134
- verification: {
135
- scanner_clean: true, smoke_clean: true,
136
- diff_stats: { files_changed: 10, lines_added: 400, lines_removed: 50 },
137
- },
138
- actualMinutes: 200,
139
- valueSummary: 'wire the auto-grader into ship.js so every task gets a quality mark before credits are paid; gates the completed→confirmed transition',
140
- });
141
- assert.ok(result.score >= 9.0, `score=${result.score}`);
142
- assert.equal(result.passed, true);
143
- assert.equal(result.grader_kind, 'auto');
144
- assert.equal(result.grader_version, grader.RUBRIC.v1.version);
145
- assert.equal(result.threshold, grader.RUBRIC.v1.threshold);
146
- });
147
-
148
- test('v1 gradeDeterministic: phantom ship scores low', () => {
149
- const result = grader.gradeDeterministic({
150
- task: { id: 100, kind: 'feature', est_minutes: 240, actual_minutes: 30, scoping_confidence: 2 },
151
- verification: {
152
- scanner_clean: true, smoke_clean: true,
153
- diff_stats: { files_changed: 0, lines_added: 0, lines_removed: 0 },
154
- },
155
- actualMinutes: 30,
156
- valueSummary: '',
157
- });
158
- assert.ok(result.score < 5.0, `score=${result.score}`);
159
- assert.equal(result.passed, false);
160
- });
161
-
162
- test('v1 gradeDeterministic: notes_md says deprecated', () => {
163
- const result = grader.gradeDeterministic({
164
- task: { id: 106, kind: 'feature', est_minutes: 60, actual_minutes: 60, scoping_confidence: 4 },
165
- verification: { scanner_clean: true, smoke_clean: true, diff_stats: { files_changed: 3, lines_added: 50, lines_removed: 5 } },
166
- actualMinutes: 60,
167
- valueSummary: 'a reasonable shipped change',
168
- });
169
- assert.match(result.notes_md, /v1.*deprecated/i);
170
- });
171
-
172
51
  // ========================================================================
173
52
  // v2 — subagent grader (the credits gate as of V3.R18 #209)
174
53
  // ========================================================================
@@ -181,17 +60,29 @@ test('v4 exports the expected surface (current panel-based gate)', () => {
181
60
  'buildPrompt', 'selectGraderModel', 'validateAndScore', 'aggregateShots',
182
61
  'runSubagent', 'runOneShot', 'gradeSubagentV3MultiShot',
183
62
  // constants
184
- 'THRESHOLD', 'VERSION', 'DEFAULT_GRADER_MODEL', 'DEFAULT_SHOTS', 'ASSUMED_BUILDER_MODEL',
63
+ 'THRESHOLD', 'VERSION', 'DEFAULT_GRADER_MODEL', 'ASSUMED_BUILDER_MODEL',
185
64
  // utilities
186
65
  'extractJson', 'extractAssistantResult', 'extractCostUsd', 'RUBRIC',
187
66
  ];
188
67
  for (const name of required) {
189
68
  assert.ok(name in grader, `missing export: ${name}`);
190
69
  }
70
+ // task 1002842: the v1 generation is retired, not merely unused.
71
+ for (const gone of ['gradeDeterministic', 'scoreEstActualBand', 'scoreValueSummary', 'scoreDiffHygiene',
72
+ 'scoreScannerSmoke', 'scoreScopingSignal', 'scoreLocNetNegative', 'scoreLearningRow', 'scoreArtRubricAvg',
73
+ 'blendWeightsForKind']) {
74
+ assert.equal(gone in grader, false, `retired v1 export came back: ${gone}`);
75
+ }
76
+ assert.equal('v1' in grader.RUBRIC, false, 'the v1 rubric block is retired');
77
+ assert.equal('v3' in grader.RUBRIC, false, 'the v3 rubric block is retired');
78
+ // The moved plumbing is one implementation, reachable from both homes.
79
+ const noArtifact = require('../modules/grading/grader-no-artifact.js');
80
+ assert.equal(grader.aggregateShots, noArtifact.aggregateShots);
81
+ assert.equal(grader.runOneShot, noArtifact.runOneShot);
191
82
  assert.ok(typeof grader.THRESHOLD === 'number');
192
83
  assert.ok(grader.THRESHOLD >= 5.0 && grader.THRESHOLD <= 10.0);
193
84
  assert.ok(grader.VERSION.startsWith('v4'), `expected v4.*, got ${grader.VERSION}`);
194
- assert.equal(grader.DEFAULT_SHOTS, 3); // kept for v3 multi-shot diagnostic path
85
+ assert.equal('DEFAULT_SHOTS' in grader, false, 'the v3 3-shot default is retired (task 1002842)');
195
86
  assert.equal(grader.DEFAULT_GRADER_MODEL, 'claude-sonnet-5'); // task 1002659: Claude 5 family pin
196
87
  assert.equal(grader.ASSUMED_BUILDER_MODEL, 'claude-fable-5');
197
88
  assert.equal(grader.FALLTHROUGH_CONFIDENCE, 0.3);
@@ -2316,6 +2207,37 @@ test('isTrivialDiff: empty changed-files or zero-line diff → false', () => {
2316
2207
  );
2317
2208
  });
2318
2209
 
2210
+ // ---------- task 1002842: the kept trivial-diff fast path still works ----------
2211
+ //
2212
+ // The v3 generation is retired, but its fast path is kept (moving it onto a v4
2213
+ // panel needs a new reviewer type — an owner decision). These pin that what was
2214
+ // kept works: ONE generalist shot by default, the generalist prompt, and the
2215
+ // moved aggregateShots collapse — driven through the runShot seam, no model spawned.
2216
+
2217
+ await testAsync('fast path: one generalist shot by default, aggregated into a gradeable result', async () => {
2218
+ const calls = [];
2219
+ const r = await grader.gradeSubagentV3MultiShot({
2220
+ task: { id: 1002842, title: 't', description: 'd', kind: 'cleanup' },
2221
+ valueSummary: 'v', diff: '+ docs line', changedFiles: ['docs/x.md'], diffStats: { files_changed: 1, lines_added: 1, lines_removed: 0 },
2222
+ builderModel: 'claude-fable-5', runOpts: {},
2223
+ runShot: async (args) => { calls.push(args); return mkPassShot(8, 8, 0.9, args.shotIndex); },
2224
+ });
2225
+ assert.equal(calls.length, 1, 'the fast path runs exactly one shot unless told otherwise');
2226
+ assert.match(calls[0].prompt, /default-fail|DEFAULT/i, 'it is the generalist default-fail prompt');
2227
+ assert.equal(r.passed, true);
2228
+ assert.equal(r.rubric_scores.functional_fidelity, 8);
2229
+ assert.equal(r.rubric_scores.code_quality, 8);
2230
+ assert.equal(r.signals.confidence, 0.9, 'the server gate reads a real confidence');
2231
+ });
2232
+
2233
+ await testAsync('fast path: a failing shot still fails the grade (not loosened by the move)', async () => {
2234
+ const r = await grader.gradeSubagentV3MultiShot({
2235
+ task: { id: 1 }, valueSummary: 'v', diff: '+ x', changedFiles: ['docs/x.md'], diffStats: {},
2236
+ runOpts: {}, runShot: async (args) => mkFailShot(4, 8, 0.9, args.shotIndex),
2237
+ });
2238
+ assert.equal(r.passed, false);
2239
+ });
2240
+
2319
2241
  // ---------- Summary ----------
2320
2242
 
2321
2243
  console.log('');
@@ -14,7 +14,7 @@
14
14
  // server's ff gate failed the ship — a PASS verdict inside a FAIL grade;
15
15
  // 3. the card had no branch for it, so it printed the ordinary FAIL block.
16
16
  // The constraint the task set: close it WITHOUT letting an unbacked low score
17
- // pass and WITHOUT adding a gate (ADR 0162). Every case below that has a low
17
+ // pass and WITHOUT adding a gate (the task's own scope: it corrects what the grade SAYS). Every case below that has a low
18
18
  // score also asserts the server still refuses it.
19
19
  //
20
20
  // Run: node tests/grader_unbacked_score.mjs
@@ -32,7 +32,7 @@
32
32
  import { strict as assert } from 'node:assert';
33
33
  import { test } from 'node:test';
34
34
  import { createRequire } from 'node:module';
35
- import { readFileSync } from 'node:fs';
35
+ import { readFileSync, readdirSync } from 'node:fs';
36
36
 
37
37
  const require = createRequire(import.meta.url);
38
38
  const { buildPrompt } = require('../modules/grading/grader-prompt.js');
@@ -78,23 +78,24 @@ test('a task with no actual_minutes at all builds the same prompt', () => {
78
78
  assert.equal(withFigure, without, 'actual_minutes changes nothing about what the panel reads');
79
79
  });
80
80
 
81
- test('scoreEstActualBand is reachable only from the retroactive audit', () => {
82
- // The one scorer that DOES read the figure. It lives in gradeDeterministic,
83
- // which the grader module's own comment calls the legacy v1 entry — its single
84
- // non-test caller is scripts/gds/audit-ships.js, a process audit run by hand
85
- // over already-shipped tasks, not a gate anything passes through. A new caller
86
- // is the same risk as wiring the figure into the prompt.
81
+ test('no grader entry reads actual_minutes — the one scorer that did is retired', () => {
82
+ // scoreEstActualBand was the ONE scorer that read the figure. It lived in the v1
83
+ // gradeDeterministic, whose only non-test caller was scripts/gds/audit-ships.js;
84
+ // task 1002842 retired all three. So the stale-figure risk this file guards is now
85
+ // closed at the source, and this pins it: nothing in the grading module may
86
+ // accept or read the figure, and the retired entry may not come back.
87
87
  const grader = require('../modules/grading/grader.js');
88
- assert.equal(typeof grader.gradeDeterministic, 'function', 'the legacy entry still exists');
89
- assert.equal(grader.gradeSubagent.length > 0, true, 'and the live entry is a different function');
88
+ assert.equal(grader.gradeDeterministic, undefined, 'the retired v1 entry stays gone');
89
+ assert.equal(grader.scoreEstActualBand, undefined, 'and so does its minutes scorer');
90
+ assert.equal(typeof grader.gradeSubagent, 'function', 'the live entry is still here');
90
91
 
91
- // gradeSubagent / gradeSubagentV3MultiShot take a single options object, so the
92
- // guard that matters is which KEYS they destructure. Neither may take minutes.
93
- const src = readFileSync(new URL('../modules/grading/grader.js', import.meta.url), 'utf8');
94
- for (const fn of ['async function gradeSubagent(', 'async function gradeSubagentV3MultiShot(']) {
95
- const at = src.indexOf(fn);
96
- assert.ok(at !== -1, `${fn} is still the entry point this pins`);
97
- const sig = src.slice(at, src.indexOf(')', at));
98
- assert.ok(!/actualMinutes/.test(sig), `${fn} does not accept actualMinutes`);
92
+ // Every grading source, not just grader.js: a new reader anywhere in the module
93
+ // is the same risk as wiring the figure into the prompt.
94
+ const dir = new URL('../modules/grading/', import.meta.url);
95
+ const files = readdirSync(dir, { recursive: true }).filter((f) => String(f).endsWith('.js'));
96
+ assert.ok(files.length > 5, 'the grading module was found');
97
+ for (const f of files) {
98
+ const src = readFileSync(new URL(String(f), dir), 'utf8');
99
+ assert.doesNotMatch(src, /actualMinutes|actual_minutes/, `${f} must not read actual_minutes`);
99
100
  }
100
101
  });
@@ -1,6 +1,6 @@
1
1
  // tests/render_check.mjs — the rendered fit check (task 1004203, ADR 0329): which pages a changed path
2
2
  // reaches, which report rows it keeps, and that /ship-check shows it as a signal that never flips its exit
3
- // (ADR 0162). DB-free and browser-free; the in-page measurement is pinned in tests/ui_design_kit_fit.mjs.
3
+ // (ADR 0329, decision 3). DB-free and browser-free; the in-page measurement is pinned in tests/ui_design_kit_fit.mjs.
4
4
  import assert from 'node:assert/strict';
5
5
  import { test } from 'node:test';
6
6
  import fs from 'node:fs';
@@ -2,8 +2,8 @@
2
2
  // accountability sweep from the grader-hardening evaluation §6 (task 1002657):
3
3
  // the override ledger, dropped blocker/major findings on passing grades (with
4
4
  // the advisory-Narc attribution filter), the false-pass spot-check, and the
5
- // outage roll-up handoff — describing and queueing ONLY (ADR 0162/0158: it
6
- // never confirms a task or flips status).
5
+ // outage roll-up handoff — describing and queueing ONLY (ADR 0158 §2: it
6
+ // never confirms a task or flips status; ADR 0162: it adds no approval step to a ship).
7
7
  import assert from 'node:assert/strict';
8
8
  import { test } from 'node:test';
9
9
  import { readFileSync, existsSync } from 'node:fs';
@@ -45,6 +45,6 @@ test('/grade-audit exists, covers the sweep legs, and stays describe-and-queue o
45
45
 
46
46
  // The hard constraint.
47
47
  assert.match(md, /never confirm/i, 'states it never confirms');
48
- assert.match(md, /ADR 0162/, 'anchors the no-new-gates constraint');
48
+ assert.match(md, /ADR 0162/, 'anchors the no-approval-step-on-a-ship constraint');
49
49
  assert.ok(!/curl[^\n]*\/confirm/.test(md), 'carries no ready-to-paste confirm curl');
50
50
  });
@@ -2,7 +2,7 @@
2
2
  // the four health axes from the grader-hardening evaluation §6 (task 1002656):
3
3
  // outage runs, pass-rate drift, cost anomalies, and stale model pins — reading
4
4
  // LIVE data surfaces, emitting exact --regrade remediation lines, and staying
5
- // read-only (describe, never confirm/flip — ADR 0162).
5
+ // read-only (describe, never confirm/flip — ADR 0158 §2; no approval step on anyone's ship — ADR 0162).
6
6
  import assert from 'node:assert/strict';
7
7
  import { test } from 'node:test';
8
8
  import { readFileSync, existsSync } from 'node:fs';
@@ -44,5 +44,5 @@ test('/grader-health exists and covers the four health axes on live data', (t) =
44
44
  assert.match(md, /--probe/, 'offers the optional live calibration probe');
45
45
  assert.match(md, /grade-smoke\.js/, 'names the probe script');
46
46
  assert.match(md, /read-only/i, 'states the read-only contract');
47
- assert.match(md, /ADR 0162/, 'anchors the no-new-gates constraint');
47
+ assert.match(md, /ADR 0162/, 'anchors the no-approval-step-on-a-ship constraint');
48
48
  });