@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.
- package/.bongos-core.json +62 -52
- package/.claude/skills/grade-recover/SKILL.md +1 -1
- package/docs/adr/0089-modular-multi-model-adversarial-grader.md +2 -0
- package/docs/adr/0302-the-third-identical-failure-stops-the-session-and-asks.md +3 -2
- package/docs/adr/0329-the-rendered-fit-check-is-a-kit-floor-and-an-advisory-ship-check-row.md +2 -2
- package/docs/adr/README.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/economy/credits.js +1 -1
- package/modules/government/protected-surfaces.json +2 -0
- package/modules/grading/grader-config.js +3 -12
- package/modules/grading/grader-model.js +50 -5
- package/modules/grading/grader-no-artifact.js +227 -2
- package/modules/grading/grader-prompt.js +1 -1
- package/modules/grading/grader-providers.neutral.json +4 -0
- package/modules/grading/grader-rubric.json +6 -86
- package/modules/grading/grader-score.js +1 -169
- package/modules/grading/grader-subagent.js +18 -52
- package/modules/grading/grader.js +26 -211
- package/modules/grading/model-provider.js +257 -0
- package/modules/lifecycle/db-ship.js +3 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/claim.js +2 -1
- package/scripts/gds/grade-smoke.js +3 -3
- package/scripts/gds/render-check.js +1 -1
- package/scripts/gds/ship-check.js +1 -1
- package/scripts/gds/ship-flow.js +1 -1
- package/scripts/gds/ship-grade-steps.js +2 -2
- package/scripts/gds/visual-review-nudge.js +2 -1
- package/src/module-api.js +1 -1
- package/tests/adr_0162_gate_citation.mjs +167 -0
- package/tests/government_protected_surfaces.mjs +14 -0
- package/tests/grade_model_provider.mjs +217 -0
- package/tests/grader.mjs +48 -126
- package/tests/grader_unbacked_score.mjs +1 -1
- package/tests/regrade_actual_minutes.mjs +18 -17
- package/tests/render_check.mjs +1 -1
- package/tests/skill_grade_audit.mjs +3 -3
- package/tests/skill_grader_health.mjs +2 -2
- package/scripts/gds/audit-ships.js +0 -319
- 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
|
-
//
|
|
282
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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
|
}
|
package/scripts/gds/claim.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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.
|
|
152
|
-
console.log(
|
|
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
|
|
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
|
|
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' },
|
package/scripts/gds/ship-flow.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|