@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,257 @@
1
+ // modules/grading/model-provider.js — the ModelProvider port (ADR 0089 §3,
2
+ // task 1002840): how a judge's model is called, as an adapter behind one seam
3
+ // instead of a hard-coded `claude` spawn.
4
+ //
5
+ // WHY. Every platform LLM call funnels through runSubagent (grader-subagent.js):
6
+ // the panel workers, the no-artifact shots, architect-audit, and the agents
7
+ // module through the grade port. Until this file that function WAS the Claude
8
+ // CLI spawn, so a judge from another model family — the property ADR 0089's
9
+ // ensemble exists for — had no way in. Now a call names a provider and the
10
+ // provider makes it. Every provider answers in the SAME shape the Claude spawn
11
+ // always has: a `claude -p --output-format json`-style envelope on stdout and the
12
+ // same error codes (SUBAGENT_SPAWN_FAILED / SUBAGENT_TIMEOUT /
13
+ // SUBAGENT_NONZERO_EXIT). So nothing downstream changes — runWorker's retry, the
14
+ // outage detection, extractAssistantResult / extractCostUsd, the LLM cache.
15
+ //
16
+ // THE MODEL REFERENCE. A model is still ONE STRING everywhere it travels
17
+ // (signals.workers[].model, grader_model, the cache key). A string with no
18
+ // provider prefix is an anthropic-cli model — every id this project uses today —
19
+ // so today's calls are byte-identical. `<provider>:<model>` routes to a
20
+ // REGISTERED provider (`deepinfra:deepseek-ai/DeepSeek-V3.1`,
21
+ // `ollama:qwen2.5-coder:32b`). Only a prefix that names a registered provider is
22
+ // split off; anything else stays a Claude model id, so a colon inside an id can
23
+ // never re-route a call by accident.
24
+ //
25
+ // PROVIDERS.
26
+ // anthropic-cli the `claude -p` spawn (grader-subagent.js registers it).
27
+ // The default, and the only provider shipped config uses.
28
+ // openai-compatible one adapter for DeepInfra / OpenRouter / Together /
29
+ // Fireworks / Groq / vLLM / Ollama — POST
30
+ // <base_url>/chat/completions with a BYOK key read from the
31
+ // env var the instance names. PROMPT-ONLY: it has no Read
32
+ // tool, so such a judge sees exactly what its prompt carries.
33
+ //
34
+ // INSTANCE CONFIG (the ADR 0062 host-owned pattern). Providers are declared in
35
+ // config/grader-providers.json at the instance root; the core ships
36
+ // grader-providers.neutral.json beside this file with NO providers, so a vanilla
37
+ // instance is Claude-only. A key never lives in a config file — only the NAME
38
+ // of the env var holding it. Declaring a provider is an owner decision: it sends
39
+ // the diff under review to that vendor and it costs money per call (ADR 0089 §3).
40
+
41
+ 'use strict';
42
+
43
+ const fs = require('node:fs');
44
+ const path = require('node:path');
45
+ const api = require('../../src/module-api');
46
+
47
+ const DEFAULT_PROVIDER = 'anthropic-cli';
48
+ const PROVIDER_NAME_RE = /^[a-z][a-z0-9-]{1,39}$/;
49
+ const API_KEY_ENV_RE = /^[A-Z][A-Z0-9_]{1,63}$/;
50
+ const KINDS = new Set(['openai-compatible']);
51
+ const DEFAULT_TIMEOUT_MS = 240_000;
52
+ const ERROR_BODY_MAX = 2000;
53
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
54
+
55
+ const providers = new Map(); // name -> { name, kind, run }
56
+
57
+ // One place a refusal is reported, so a malformed instance config is loud
58
+ // without scattering console calls (the fitness console ratchet counts them).
59
+ function refuse(msg) {
60
+ console.error(`grade model-provider: ${msg}`);
61
+ }
62
+
63
+ function registerModelProvider(provider) {
64
+ if (!provider || typeof provider.run !== 'function' || typeof provider.name !== 'string' || !provider.name) {
65
+ throw new TypeError('registerModelProvider: a provider needs a name and a run({ prompt, opts }) function');
66
+ }
67
+ const held = providers.get(provider.name);
68
+ if (held && held !== provider) throw new Error(`model provider '${provider.name}' is already registered`);
69
+ providers.set(provider.name, provider);
70
+ return provider;
71
+ }
72
+
73
+ function resolveModelProvider(name = DEFAULT_PROVIDER) {
74
+ const p = providers.get(name);
75
+ if (!p) throw new Error(`model provider '${name}' is not registered`);
76
+ return p;
77
+ }
78
+
79
+ function listModelProviders() {
80
+ return [...providers.keys()].sort();
81
+ }
82
+
83
+ // `<provider>:<model>` → { provider, model }, split ONLY when the prefix names a
84
+ // registered provider other than the default; everything else is one Claude id.
85
+ function parseModelRef(ref) {
86
+ const s = String(ref == null ? '' : ref);
87
+ const i = s.indexOf(':');
88
+ if (i > 0) {
89
+ const name = s.slice(0, i);
90
+ if (name !== DEFAULT_PROVIDER && providers.has(name)) return { provider: name, model: s.slice(i + 1) };
91
+ }
92
+ return { provider: DEFAULT_PROVIDER, model: s };
93
+ }
94
+
95
+ function codeError(code, message, extra = {}) {
96
+ return Object.assign(new Error(message), { code, ...extra });
97
+ }
98
+
99
+ // base_url must be https, or plain http to THIS machine only (a local vLLM /
100
+ // Ollama): a key must never cross a network in cleartext.
101
+ function checkBaseUrl(raw) {
102
+ let u;
103
+ try { u = new URL(String(raw)); } catch { return null; }
104
+ const loopback = LOOPBACK_HOSTS.has(u.hostname);
105
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) return null;
106
+ if (u.username || u.password || u.search || u.hash) return null;
107
+ return { url: u.toString().replace(/\/+$/, ''), loopback };
108
+ }
109
+
110
+ // Validate one declared provider: { def } normalised, or { error } saying why
111
+ // not — never throws, so one bad entry cannot take the grader down.
112
+ function validateProviderDef(name, def) {
113
+ if (!PROVIDER_NAME_RE.test(name) || name === DEFAULT_PROVIDER) return { error: `provider name '${name}' must match ${PROVIDER_NAME_RE} and not be '${DEFAULT_PROVIDER}'` };
114
+ if (!def || typeof def !== 'object') return { error: `provider '${name}' must be an object` };
115
+ if (!KINDS.has(def.kind)) return { error: `provider '${name}': kind must be one of ${[...KINDS].join(', ')}` };
116
+ const base = checkBaseUrl(def.base_url);
117
+ if (!base) return { error: `provider '${name}': base_url must be https:// (http:// only to localhost), with no credentials or query` };
118
+ const keyEnv = def.api_key_env == null ? null : String(def.api_key_env);
119
+ if (keyEnv != null && !API_KEY_ENV_RE.test(keyEnv)) return { error: `provider '${name}': api_key_env must be an env var NAME (${API_KEY_ENV_RE}) — never the key itself` };
120
+ if (keyEnv == null && !base.loopback) return { error: `provider '${name}': a remote provider needs api_key_env` };
121
+ const prices = {};
122
+ for (const [model, p] of Object.entries(def.price_per_mtok || {})) {
123
+ const input = Number(p && p.input);
124
+ const output = Number(p && p.output);
125
+ if (!(Number.isFinite(input) && input >= 0 && Number.isFinite(output) && output >= 0)) {
126
+ return { error: `provider '${name}': price_per_mtok['${model}'] needs non-negative input and output` };
127
+ }
128
+ prices[model] = { input, output };
129
+ }
130
+ return { def: { name, kind: def.kind, baseUrl: base.url, apiKeyEnv: keyEnv, prices } };
131
+ }
132
+
133
+ // USD for one call from the reply's token usage, or null when the model has no
134
+ // declared price — a cost signal never fabricates a number it does not have.
135
+ function priceCall(prices, model, usage) {
136
+ const p = prices[model];
137
+ if (!p || !usage) return null;
138
+ const inTok = Number(usage.prompt_tokens);
139
+ const outTok = Number(usage.completion_tokens);
140
+ if (!Number.isFinite(inTok) || !Number.isFinite(outTok)) return null;
141
+ return (inTok * p.input + outTok * p.output) / 1_000_000;
142
+ }
143
+
144
+ // The openai-compatible adapter. `fetchImpl` and `env` are injectable so the
145
+ // whole adapter is testable against a stub with no key and no network.
146
+ function createOpenAICompatibleProvider(def, { fetchImpl = globalThis.fetch, env = process.env } = {}) {
147
+ const run = async ({ prompt, opts = {} }) => {
148
+ const started = Date.now();
149
+ const model = String(opts.model || '');
150
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
151
+ const key = def.apiKeyEnv ? env[def.apiKeyEnv] : null;
152
+ // Refused BEFORE any request: no key means nothing is sent anywhere.
153
+ if (def.apiKeyEnv && !key) {
154
+ throw codeError('SUBAGENT_SPAWN_FAILED', `provider ${def.name}: no API key — set ${def.apiKeyEnv} (bring-your-own key)`, { run_ms: 0, stdout: '', stderr: '' });
155
+ }
156
+ const scrub = (t) => (key ? String(t).split(key).join('[redacted]') : String(t));
157
+ const controller = new AbortController();
158
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
159
+ let res;
160
+ let text;
161
+ try {
162
+ res = await fetchImpl(`${def.baseUrl}/chat/completions`, {
163
+ method: 'POST',
164
+ headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}` } : {}) },
165
+ body: JSON.stringify({ model, messages: [{ role: 'user', content: String(prompt) }] }),
166
+ signal: controller.signal,
167
+ });
168
+ text = await res.text();
169
+ } catch (err) {
170
+ const run_ms = Date.now() - started;
171
+ if (controller.signal.aborted) {
172
+ throw codeError('SUBAGENT_TIMEOUT', `provider ${def.name}: timed out after ${timeoutMs}ms`, { run_ms, stdout: '', stderr: '' });
173
+ }
174
+ throw codeError('SUBAGENT_SPAWN_FAILED', `provider ${def.name}: request failed: ${scrub(err && err.message)}`, { run_ms, stdout: '', stderr: scrub(err && err.message) });
175
+ } finally {
176
+ clearTimeout(timer);
177
+ }
178
+ const run_ms = Date.now() - started;
179
+ if (!res.ok) {
180
+ throw codeError('SUBAGENT_NONZERO_EXIT', `provider ${def.name}: HTTP ${res.status}`, {
181
+ exit_code: res.status, run_ms, stdout: '', stderr: scrub(text).slice(0, ERROR_BODY_MAX),
182
+ });
183
+ }
184
+ let body = null;
185
+ try { body = JSON.parse(text); } catch { /* an unparseable 200 → empty result → the worker's parse_error path */ }
186
+ const content = body && body.choices && body.choices[0] && body.choices[0].message
187
+ ? body.choices[0].message.content : null;
188
+ const usage = body && body.usage ? body.usage : null;
189
+ const cost = priceCall(def.prices, model, usage);
190
+ const envelope = {
191
+ type: 'result',
192
+ result: typeof content === 'string' ? content : '',
193
+ ...(cost != null ? { total_cost_usd: cost } : {}),
194
+ provider: def.name,
195
+ model,
196
+ usage,
197
+ };
198
+ return { stdout: JSON.stringify(envelope), stderr: '', run_ms, exit_code: 0, cost_usd: cost };
199
+ };
200
+ return { name: def.name, kind: def.kind, run };
201
+ }
202
+
203
+ // ---------- instance config ----------
204
+ //
205
+ // The host's config/grader-providers.json, else the neutral starter beside this
206
+ // file (which declares none). A missing instance file is the ordinary case; one
207
+ // that does not parse falls back to the neutral starter — fail to vanilla, never
208
+ // to dead — and each invalid entry is refused by name while the rest load.
209
+ const NEUTRAL_PATH = path.join(__dirname, 'grader-providers.neutral.json');
210
+ const INSTANCE_PATH = path.join(api.resolveInstanceRoot(), 'config', 'grader-providers.json');
211
+
212
+ function readPack(file) {
213
+ let raw;
214
+ try { raw = fs.readFileSync(file, 'utf8'); } catch { return null; }
215
+ try {
216
+ const parsed = JSON.parse(raw);
217
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
218
+ } catch { return null; }
219
+ }
220
+
221
+ function loadProviderConfig({ instancePath = INSTANCE_PATH, neutralPath = NEUTRAL_PATH } = {}) {
222
+ const instance = readPack(instancePath);
223
+ const pack = instance || readPack(neutralPath) || {};
224
+ const defs = [];
225
+ const refused = [];
226
+ const declared = pack.providers && typeof pack.providers === 'object' && !Array.isArray(pack.providers) ? pack.providers : {};
227
+ for (const [name, def] of Object.entries(declared)) {
228
+ const v = validateProviderDef(name, def);
229
+ if (v.error) refused.push(v.error); else defs.push(v.def);
230
+ }
231
+ return { source: instance ? instancePath : neutralPath, defs, refused };
232
+ }
233
+
234
+ // Register every provider the instance declares. Idempotent: an already
235
+ // registered name is left alone. Returns the provider names now resolvable.
236
+ function registerConfiguredProviders(opts = {}) {
237
+ const { defs, refused } = loadProviderConfig(opts);
238
+ for (const reason of refused) refuse(`${reason} — refused, grading continues without it`);
239
+ for (const def of defs) {
240
+ if (!providers.has(def.name)) registerModelProvider(createOpenAICompatibleProvider(def, opts));
241
+ }
242
+ return listModelProviders();
243
+ }
244
+
245
+ registerConfiguredProviders();
246
+
247
+ module.exports = {
248
+ DEFAULT_PROVIDER,
249
+ createOpenAICompatibleProvider,
250
+ listModelProviders,
251
+ loadProviderConfig,
252
+ parseModelRef,
253
+ registerConfiguredProviders,
254
+ registerModelProvider,
255
+ resolveModelProvider,
256
+ validateProviderDef,
257
+ };
@@ -277,9 +277,9 @@ async function resolveClaim({ claimId, builderId, outcome, notesMd, valueSummary
277
277
  // The ordering below is NOT for the grader, whatever this comment used to
278
278
  // say. V3.R18 (#209) moved the grader out of resolveClaim — see the block
279
279
  // after this one — and the live panel never receives actual_minutes at all
280
- // (modules/grading/grader-prompt.js buildPrompt prints est_minutes only;
281
- // scoreEstActualBand lives in gradeDeterministic, whose one non-test caller
282
- // is the retroactive audit in scripts/gds/audit-ships.js). The seal must
280
+ // (modules/grading/grader-prompt.js buildPrompt prints est_minutes only; the
281
+ // one scorer that read actual_minutes was retired with the v1 grader in task
282
+ // 1002842). The seal must
283
283
  // still precede the recompute, because the recompute SUMs the row it writes.
284
284
  // tests/regrade_actual_minutes.mjs pins the panel half of that.
285
285
  await sealSessionLog(client, {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1036",
3
+ "version": "1.19.1038",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1036",
9
+ "version": "1.19.1038",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1036",
3
+ "version": "1.19.1038",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -7497,5 +7497,21 @@
7497
7497
  "id": "1004174",
7498
7498
  "text": "The owner of a hosted project can now upgrade it themselves: the \"you're behind\" banner on their project links to the Deploy page on the hub, which lets a project's owner move their own project, and only their own."
7499
7499
  }
7500
+ ],
7501
+ "1.19.1037": [
7502
+ {
7503
+ "id": "1002842",
7504
+ "text": "The grader's retired first generation and an unused one-off audit script are removed, and the pieces a newer reviewer still depends on now live with it — nothing about how work is graded changes."
7505
+ },
7506
+ {
7507
+ "id": "1004280",
7508
+ "text": "The project's own notes no longer claim a past decision banned all new checks; each one now gives its real reason, and a test stops the wrong claim coming back."
7509
+ }
7510
+ ],
7511
+ "1.19.1038": [
7512
+ {
7513
+ "id": "1002840",
7514
+ "text": "The grader can now call AI models from other companies through one plug-in point, ready for a second-opinion reviewer; nothing changes today — it still uses only Claude and spends nothing new."
7515
+ }
7500
7516
  ]
7501
7517
  }
@@ -739,7 +739,8 @@ async function main() {
739
739
  // last thing the builder reads before deciding how to work — task 1267.
740
740
  printDisciplineMode(task);
741
741
  // task 1004202: a task that changes a page is told to use /design and get its
742
- // human's OK on the before/after. A nudge, never a gate (ADR 0162).
742
+ // human's OK on the before/after. A nudge, never a gate: gating on that OK would
743
+ // put a person's approval in the land path, which ADR 0162 retired.
743
744
  try {
744
745
  const nudge = visualReviewNudge(task);
745
746
  if (nudge) console.log(`\n${nudge.join('\n')}`);
@@ -2,7 +2,7 @@
2
2
  // Subagent grader smoke test — GDS-V3 task V3.R18 (#209) → MAS Phase 1 #395.
3
3
  //
4
4
  // Runs two synthetic ship scenarios end-to-end through the v3 multi-shot
5
- // subagent grader (DEFAULT_SHOTS × cross-family model, unanimous-PASS gate).
5
+ // subagent grader (the worker panel, cross-family model, unanimous-PASS gate).
6
6
  // Asserts:
7
7
  //
8
8
  // - Known-GOOD ship: a tiny, complete change → grade passes (unanimous
@@ -148,8 +148,8 @@ function summarize(result, label) {
148
148
  // -------- Main --------
149
149
 
150
150
  (async () => {
151
- console.log(`Running subagent grader smoke (v${grader.VERSION}, model=${grader.DEFAULT_GRADER_MODEL}, ${grader.DEFAULT_SHOTS}× shots, threshold=${grader.THRESHOLD})`);
152
- console.log('Two scenarios will run sequentially. Each spawns 3 parallel shots.');
151
+ console.log(`Running subagent grader smoke (v${grader.VERSION}, model=${grader.DEFAULT_GRADER_MODEL}, ${grader.WORKERS.DEFAULT_PANEL.length}-worker panel, threshold=${grader.THRESHOLD})`);
152
+ console.log(`Two scenarios will run sequentially. Each runs the ${grader.WORKERS.DEFAULT_PANEL.length}-worker panel.`);
153
153
 
154
154
  let assertFailures = 0;
155
155
 
@@ -7,7 +7,7 @@
7
7
  // count that still ran past its card (task 1003480) and a second grid child landing on the first (task 1003078)
8
8
  // both shipped looking correct in code and were found only by opening a browser.
9
9
  //
10
- // A SIGNAL, never a gate (ADR 0162): /ship-check shows it as an advisory row; ship.js and the grader never
10
+ // A SIGNAL, never a gate (ADR 0329, decisions 3-4): /ship-check shows it as an advisory row; ship.js and the grader never
11
11
  // read it. The measurement is the kit's `text-fit` + `sibling-overlap` floors (modules/ui-design/kit/lib.js);
12
12
  // this file only picks the pages, runs the kit's renderer at 1280 + 390, and keeps those two floors.
13
13
  //
@@ -25,7 +25,7 @@ const REPO_ROOT = path.resolve(__dirname, '..', '..');
25
25
 
26
26
  // Each step: a label, the argv to run (node scripts run with THIS node), the command that heals it,
27
27
  // and how to read a non-zero exit. `optional` steps are skipped when their script is absent. An `advisory`
28
- // step is a signal, never a gate (ADR 0162): a non-zero exit reads `warn` (3 = no browser reads `skipped`).
28
+ // step is a signal, never a gate (ADR 0329, decision 3): a non-zero exit reads `warn` (3 = no browser reads `skipped`).
29
29
  // `onlyWith` names the flag that adds a step; `skipWith` the flag that leaves one out.
30
30
  const STEPS = [
31
31
  { key: 'fitness', label: 'fitness.js (architecture + budgets + freshness gate)', argv: ['scripts/gds/fitness.js'], fix: 'read the ✗ lines above; each names its fix' },
@@ -496,7 +496,7 @@ async function shipMain() {
496
496
  passed,
497
497
  // touches[] cleanup [4/8] (task 879): the drift scanner is gone; with no
498
498
  // scanner there is nothing to drift, so this audit-trail signal
499
- // (grader.scoreScannerSmoke) is vacuously clean.
499
+ // (read by the retired v1 grader, task 1002842) is vacuously clean.
500
500
  scanner_clean: true,
501
501
  smoke_clean: smoke.clean,
502
502
  },
@@ -314,7 +314,7 @@ function formatOutageDiagnostic(g, cliFinding) {
314
314
 
315
315
  // cliFinding: the preflight `claude CLI` row, injected by printGradeOutcome so a
316
316
  // dark panel can say "your CLI is logged out" instead of leaving the builder to
317
- // guess. Report-only — it never changes the outcome (ADR 0162: no new gates).
317
+ // guess. Report-only — it never changes the outcome: it explains an outage, it does not decide one.
318
318
  function formatGradeOutcome(g, taskId, cliFinding = null) {
319
319
  const out = [];
320
320
  // #943: a TOTAL grader outage (every gating worker failed to spawn / the
@@ -466,7 +466,7 @@ function printGradeOutcome(g, taskId) {
466
466
  // task 1003313: on a TOTAL outage only, ask the existing preflight whether the
467
467
  // local `claude` CLI is launchable and logged in — the two faults that produce
468
468
  // this exact all-workers-dead signature. Report-only and outage-only, so the
469
- // happy path pays nothing and no ship is newly gated (ADR 0162).
469
+ // happy path pays nothing and no ship's outcome depends on the probe.
470
470
  let cliFinding = null;
471
471
  if (g?.signals?.panel_outcome === 'unavailable') {
472
472
  try {
@@ -5,7 +5,8 @@
5
5
  // real browser, and get the human's OK on the before/after before shipping — the
6
6
  // project default for every builder since the owner's 2026-09-23 decision.
7
7
  //
8
- // A NUDGE, never a gate (ADR 0162). "Is this UI work" is the ONE predicate the
8
+ // A NUDGE, never a gate: as a gate, the human's OK would be a person's approval in
9
+ // the land path, which ADR 0162 retired. "Is this UI work" is the ONE predicate the
9
10
  // artist review and the grader's copy lens already use (lifecycle/cascade.js), so
10
11
  // the three cannot disagree. Nothing has changed yet at claim time, so it is fed
11
12
  // the paths the task NAMES: its touches[] and any modules/*/public/ path written
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1036'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1038'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,167 @@
1
+ // tests/adr_0162_gate_citation.mjs — "no new gates" is not what ADR 0162 decided; no surface
2
+ // may cite 0162 as the reason something is a signal and not a gate (task 1004280).
3
+ //
4
+ // WHAT WENT WRONG. Task 1003573 corrected one slogan cited to ADR 0162 ("verdicts are advisory,
5
+ // never self-executing" — ADR 0158 §2's rule; tests/adr_0162_citation.mjs pins that). A sibling
6
+ // slogan rode along: "no new gates (ADR 0162)", "a signal, never a gate (ADR 0162)", "NOT A
7
+ // PIPELINE GATE (ADR 0162)". It is broader than the ADR. 0162 retired REVIEW gates — a gate that
8
+ // needs a particular person's approval before work can land — and its own table keeps four
9
+ // automatic controls, three of which fail CLOSED (the grader among them). ADR 0241 ("What ADR 0162
10
+ // actually forbids") states the test 0162 leaves behind: an automatic, state-read, rank-consistent
11
+ // gate with a named escape hatch is admissible. So "this is not a gate because 0162" cites a rule
12
+ // 0162 does not make, and it hides the surface's real reason for staying advisory.
13
+ //
14
+ // The surfaces keep their choice (most were right to stay advisory); only the reason changed:
15
+ // 0162 for "no human approval in the land path", and the surface's own reason otherwise.
16
+ //
17
+ // WHAT THIS PINS. (1) The premise, read from the ADRs: 0162 keeps fail-closed automatic controls
18
+ // and never says "no new gates"; 0241 says what 0162 forbids. (2) No tracked surface cites 0162
19
+ // with the gate shorthand, in any of the shapes the 2026-09-25 sweep found.
20
+ //
21
+ // Run: node --test tests/adr_0162_gate_citation.mjs
22
+
23
+ import { test } from 'node:test';
24
+ import assert from 'node:assert/strict';
25
+ import { readFileSync } from 'node:fs';
26
+ import { spawnSync } from 'node:child_process';
27
+ import path from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+ const read = (rel) => readFileSync(path.join(ROOT, rel), 'utf8');
32
+
33
+ const ADR_0162 = 'docs/adr/0162-review-gates-retired-rank-consistent-ci-only.md';
34
+ const ADR_0241 = 'docs/adr/0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md';
35
+
36
+ // A match is a line that gives 0162 as the reason something is not (or must not be) a gate.
37
+ const GATE_SHORTHANDS = [
38
+ // The advisory claim, then 0162 as its source: "no new gates (ADR 0162)", "A SIGNAL, never a
39
+ // gate (ADR 0162)", "NOT A PIPELINE GATE (ADR 0162)", "no ship is newly gated (ADR 0162)",
40
+ // "WITHOUT adding a gate (ADR 0162)", "It gates NOTHING (ADR 0162 / ADR 0241)", "a new gate on
41
+ // the ship path (ADR 0162)", "It had to stay a signal (ADR 0162)", "never flips its exit (ADR 0162)".
42
+ /\b(?:no new gates?|never a gate|not a (?:pipeline )?gate|newly gated|adding a gate|gates nothing|new gate on the ship path|stay a signal|never flips its exit)\b[^\n()]{0,40}\(ADR 0162\b/i,
43
+ // 0162, then the shorthand as its gloss: "ADR 0162 — no new gates", "(ADR 0162: no new gates)",
44
+ // "[ADR 0162](…) (no new gates)".
45
+ /\b0162\b[\])]?\s*(?:\(|—|–|:)\s*no new gates?\b/i,
46
+ // A describe-only posture cited to 0162: "(describe, never confirm/flip — ADR 0162)". That is
47
+ // 0158 §2's rule — the sibling test's subject, in a shape it does not match.
48
+ /\bnever confirm\/flip\s*[—–-]\s*ADR 0162\b/i,
49
+ ];
50
+
51
+ // Deliberately NOT corrected, each with its reason. Shrink-only: when one is fixed, delete its
52
+ // row — the last test fails until you do, so a stale exemption cannot outlive what it excused.
53
+ const KNOWN_DEFERRED = new Map([
54
+ // HARD-FLOOR surfaces (scripts/gds/gate-review.js HARD_FLOOR_GLOBS lists `.claude/hooks/`):
55
+ // even a comment-only edit escalates to the owner, which a citation fix does not justify on
56
+ // its own. Correct them with the next real edit (the redteam-patrol.yml precedent, task 1003573).
57
+ ['.claude/hooks/allow-rule-guard.js', 'hard-floor gate surface'],
58
+ ['.claude/hooks/git-guard.js', 'hard-floor gate surface'],
59
+ // A hall PAGE script: any edit under modules/*/public/ is UI work, so its ship files an artist
60
+ // review that holds the deploy (ADR 0241). A comment fix does not justify that; correct it
61
+ // with the next real edit to the page.
62
+ ['modules/hall-ui/public/studio.js', 'page script — an edit files an artist review'],
63
+ ]);
64
+
65
+ const HISTORICAL = /^docs\/session-logs\//;
66
+ // This file and its sibling quote the shapes they hunt, as examples.
67
+ const SELF = new Set(['tests/adr_0162_gate_citation.mjs', 'tests/adr_0162_citation.mjs']);
68
+ const TEXT = /\.(?:md|js|mjs|cjs|json|ya?ml|sql|sh|html|txt)$/;
69
+ // A line that NAMES the slogan as its subject is discussing it, not citing by it.
70
+ const DISCUSSES_SLOGAN = /paraphrase|shorthand/i;
71
+ // Markdown link targets carry the ADR's filename, which would sit between a citation and its gloss.
72
+ const stripLinkTargets = (s) => s.replace(/\]\([^)\s]*\)/g, ']');
73
+ const continuation = (s) => (s || '').replace(/^\s*(?:\/\/+|#+|--|\*|>|-)?\s*/, '');
74
+
75
+ function offendersIn(rel, text) {
76
+ const lines = stripLinkTargets(text).split('\n');
77
+ const hits = [];
78
+ for (let i = 0; i < lines.length; i++) {
79
+ const here = lines[i];
80
+ if (DISCUSSES_SLOGAN.test(here)) continue;
81
+ // Join the next line so a citation wrapped mid-phrase still reads as one; only a match
82
+ // that STARTS on this line is this line's.
83
+ const window = `${here} ${continuation(lines[i + 1])}`;
84
+ if (!/0162/.test(window)) continue;
85
+ for (const re of GATE_SHORTHANDS) {
86
+ const m = re.exec(window);
87
+ if (m && m.index < here.length) { hits.push(`${rel}:${i + 1}: ${here.trim().slice(0, 140)}`); break; }
88
+ }
89
+ }
90
+ return hits;
91
+ }
92
+
93
+ function trackedTextFiles() {
94
+ const r = spawnSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
95
+ if (r.status !== 0) return null; // a packaged core is not a git checkout
96
+ return r.stdout.split('\0').filter((f) => f && TEXT.test(f));
97
+ }
98
+
99
+ test('the premise: ADR 0162 keeps automatic gates that fail closed, and never says "no new gates"', () => {
100
+ const adr0162 = read(ADR_0162);
101
+ assert.match(adr0162, /Review requirements on `main` are \*\*retired permanently\*\*/, 'what 0162 retired');
102
+ const failsClosed = adr0162.split('\n').filter((l) => /^\|.*\|\s*closed\s*\|\s*$/.test(l));
103
+ assert.ok(failsClosed.length >= 3,
104
+ `0162's compensating-controls table should keep >=3 controls that fail CLOSED (found ${failsClosed.length}) — `
105
+ + 'if the ADR changed, re-ask whether "no new gates" became its rule before re-pointing these citations');
106
+ assert.doesNotMatch(adr0162, /no new gates?/i, 'ADR 0162 never says "no new gates"');
107
+ assert.match(read(ADR_0241), /^## What ADR 0162 actually forbids$/m, 'ADR 0241 is where the narrower rule is written');
108
+ });
109
+
110
+ test('the detector recognises every shape the sweep corrected, and passes the true citations', () => {
111
+ const flag = (line) => offendersIn('probe.md', line).length > 0;
112
+ for (const bad of [
113
+ '- **ADR 0162 — no new gates.** This skill describes and recovers; it never inserts a human-approval step',
114
+ '// A SIGNAL, never a gate (ADR 0162): /ship-check shows it as an advisory row; ship.js and the grader never',
115
+ '// step is a signal, never a gate (ADR 0162): a non-zero exit reads `warn` (3 = no browser reads `skipped`).',
116
+ '// A NUDGE, never a gate (ADR 0162). "Is this UI work" is the ONE predicate the',
117
+ ' // human\'s OK on the before/after. A nudge, never a gate (ADR 0162).',
118
+ '// guess. Report-only — it never changes the outcome (ADR 0162: no new gates).',
119
+ ' // happy path pays nothing and no ship is newly gated (ADR 0162).',
120
+ '- **Relates to:** [ADR 0162](0162-review-gates-retired-rank-consistent-ci-only.md) (no new gates), [ADR 0197](0197-the-ui-design-module.md) (the kit)',
121
+ 'or both. It had to stay a signal (ADR 0162).',
122
+ '- **Block the fourth attempt.** That is a new gate on the ship path (ADR 0162), and a wrong',
123
+ 'shows them as an ADVISORY row that never flips its exit (ADR 0162). Not a grader worker',
124
+ '// NOT A PIPELINE GATE (ADR 0162): it gates no merge, no CI check, no ship.',
125
+ '// It gates NOTHING (ADR 0162 / ADR 0241): nothing here holds a ship or a deploy.',
126
+ '// pass and WITHOUT adding a gate (ADR 0162). Every case below that has a low',
127
+ '// read-only (describe, never confirm/flip — ADR 0162).',
128
+ ]) assert.ok(flag(bad), `should flag: ${bad}`);
129
+
130
+ for (const good of [
131
+ '- **Related:** ADR 0162 (no new human gates on the ship path)',
132
+ 'No review requirement — gates are CI checks, not human approvals (ADR 0162).',
133
+ '- **Relates to:** [ADR 0162](0162-review-gates-retired-rank-consistent-ci-only.md) (review gates retired: no human approval in the land path)',
134
+ 'no confirms, no status flips, and no new approval step on anyone\'s ship (ADR 0162). The remediation lines',
135
+ '// A NUDGE, never a gate: as a gate, the human\'s OK would be a person\'s approval in\n// the land path, which ADR 0162 retired.',
136
+ '// A SIGNAL, never a gate (ADR 0329, decisions 3-4): /ship-check shows it as an advisory row',
137
+ 'It never holds a builder\'s ship, which is what keeps it compatible with ADR 0162.',
138
+ 'This matters, because the shorthand "no new gates (ADR 0162)" is broader than the ADR',
139
+ ]) assert.ok(!flag(good), `should pass: ${good}`);
140
+ });
141
+
142
+ test('no tracked surface cites ADR 0162 as the reason something is not a gate', (t) => {
143
+ const files = trackedTextFiles();
144
+ if (!files) return t.skip('not a git checkout — nothing tracked to scan');
145
+ const offenders = [];
146
+ for (const rel of files) {
147
+ if (SELF.has(rel) || HISTORICAL.test(rel) || KNOWN_DEFERRED.has(rel)) continue;
148
+ let text;
149
+ try { text = readFileSync(path.join(ROOT, rel), 'utf8'); } catch { continue; }
150
+ if (!text.includes('0162')) continue;
151
+ offenders.push(...offendersIn(rel, text));
152
+ }
153
+ assert.deepEqual(offenders, [],
154
+ 'these cite ADR 0162 for "no new gates" / "a signal, never a gate". 0162 retired gates that need a '
155
+ + 'particular person\'s approval in the land path, and keeps automatic gates that fail closed (ADR 0241). '
156
+ + 'Cite 0162 for "no human approval in the land path", and name the surface\'s own reason for staying advisory.');
157
+ });
158
+
159
+ test('every deferred exemption still has something to excuse (shrink-only)', (t) => {
160
+ if (!trackedTextFiles()) return t.skip('not a git checkout');
161
+ for (const [rel, why] of KNOWN_DEFERRED) {
162
+ let text = '';
163
+ try { text = read(rel); } catch { /* deleted: the exemption is stale too */ }
164
+ assert.ok(offendersIn(rel, text).length > 0,
165
+ `${rel} (${why}) no longer cites 0162 for the gate shorthand — delete its KNOWN_DEFERRED row`);
166
+ }
167
+ });
@@ -305,6 +305,20 @@ const ADDED_SINCE_R101 = [
305
305
  + 'deterministic veto, so an edit here can switch off the protected-path gate '
306
306
  + 'for every grade at once.',
307
307
  },
308
+ // task 1002840 (ADR 0089 §3): the ModelProvider port and its neutral config.
309
+ {
310
+ after: 'modules/grading/grade-engine.js',
311
+ glob: 'modules/grading/model-provider.js',
312
+ why: 'the ModelProvider port every grader model call goes through. It decides which '
313
+ + 'vendor receives the diff under review and with which key, so an edit here can '
314
+ + 're-route or blind the whole panel.',
315
+ },
316
+ {
317
+ after: 'modules/grading/model-provider.js',
318
+ glob: 'modules/grading/grader-providers.neutral.json',
319
+ why: 'the core neutral provider set, which declares none. An entry added here would '
320
+ + 'send every instance\'s diffs to a vendor by default.',
321
+ },
308
322
  ];
309
323
 
310
324
  // Surfaces RENAMED since R101 — still protected, under a new path. Distinct from