@bongos/core 1.19.1037 → 1.19.1039

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.
@@ -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
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1037",
3
+ "version": "1.19.1039",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1037",
9
+ "version": "1.19.1039",
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.1037",
3
+ "version": "1.19.1039",
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",
@@ -7507,5 +7507,21 @@
7507
7507
  "id": "1004280",
7508
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
7509
  }
7510
+ ],
7511
+ "1.19.1038": [
7512
+ {
7513
+ "id": "1004289",
7514
+ "text": "When the code host refuses to run the automatic tests at all, for example because the account's allowance ran out, the shipping tool now says so plainly and names the owner's fix, instead of telling the builder to fix code tha"
7515
+ },
7516
+ {
7517
+ "id": "1002840",
7518
+ "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."
7519
+ }
7520
+ ],
7521
+ "1.19.1039": [
7522
+ {
7523
+ "id": "1004290",
7524
+ "text": "A new release of the core is now published only after the check that it can set up a brand-new empty database has passed, so a release that cannot install cleanly can no longer reach projects."
7525
+ }
7510
7526
  ]
7511
7527
  }
@@ -169,6 +169,11 @@ const CARDS = Object.freeze({
169
169
  'An automatic test failed on your work, so it has not been added to the project.',
170
170
  'I will run that test here, fix what it found, and send the work again. Sending the same work again would fail the same way.',
171
171
  null, FAULT),
172
+ // task 1004289: the tests never ran at all (the account cannot run them right now).
173
+ ci_not_started: card(
174
+ 'The automatic tests never ran on your work: the code host did not start them, because the account has run out of test time or has a payment problem. Nothing was tested, so nothing needs fixing.',
175
+ 'I will not change the work. Once the owner restores the test time, I will start the tests again on the same work and it will be added on its own.',
176
+ 'The project owner, to restore the account\'s test time on the code host.', FAULT),
172
177
  non_required_check_failing: card(
173
178
  'An optional automatic test failed on your work.',
174
179
  'I will look at what it found. It does not stop the work being added, but it is usually worth fixing.',
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/publish-integration-gate.js — the publish lane waits for the integration
3
+ // lane's verdict on the exact commit it is about to release (task 1004290).
4
+ //
5
+ // WHY. publish.yml fires on the `unit` workflow's success, and `unit` is DB-free by
6
+ // design. The proof that the packaged core can set up an EMPTY database and serve a
7
+ // DB-backed route — tests/consumer_layout_boot.mjs, task 1002863 — runs in the
8
+ // `integration` workflow, on the same main push. Without this gate that result arrived
9
+ // after the publish and gated nothing: a core that installs and boots but cannot migrate
10
+ // its own database still reached npm, and from there the nightly tenant upgrade sweep.
11
+ //
12
+ // WHAT IT COSTS, MEASURED 2026-09-26 (not assumed). Both lanes start on the same push, and
13
+ // integration is the SHORTER one: on the two commits right after task 1002863 landed,
14
+ // integration took 247s and 245s against unit's 351s and 335s. So when unit's success fires
15
+ // the publish, integration has usually already answered and this gate is a single API
16
+ // read. It runs inside the existing publish job — no new job — and it polls only in the
17
+ // rare case integration is still going. Over 299 main pushes (2026-09-20 → 26) integration
18
+ // was green 254 times, cancelled 41 times (a newer merge superseded it) and red 3 times,
19
+ // all three the Actions-budget outage of 2026-09-25 — so it is not a flaky lane that would
20
+ // stall every release.
21
+ //
22
+ // THE VERDICTS (decideIntegrationGate, pure):
23
+ // go the newest push-event run on this commit succeeded → publish
24
+ // wait no run yet, or it is still going → poll
25
+ // superseded it was cancelled — almost always a newer merge's run replacing it under
26
+ // integration.yml's cancel-in-progress group. Nothing is published; that
27
+ // newer merge fires its own publish, which asks about its own commit.
28
+ // red anything else (failure, timed_out, startup_failure …) → refuse, loudly
29
+ // Only PUSH-event runs count: the weekly scheduled run on main carries HEAD's sha but runs
30
+ // only the parked-set ratchet, so its conclusion says nothing about this commit.
31
+ //
32
+ // OUTPUT: `go=true|false` to $GITHUB_OUTPUT. Exit 0 for go and superseded, 1 for red,
33
+ // timeout or an unreadable API (a refusal that must be seen), 2 for bad arguments.
34
+ //
35
+ // node scripts/gds/publish-integration-gate.js --sha <40-hex> --repo <owner/name>
36
+ // [--timeout-min 12] [--interval-s 20] token: GH_TOKEN or GITHUB_TOKEN
37
+
38
+ 'use strict';
39
+
40
+ const fs = require('node:fs');
41
+
42
+ const WORKFLOW_FILE = 'integration.yml';
43
+ const DEFAULT_TIMEOUT_MIN = 12; // inside publish.yml's 20-minute job, leaving room to pack + publish
44
+ const DEFAULT_INTERVAL_S = 20;
45
+ const SHA_RE = /^[0-9a-f]{40}$/;
46
+ const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
47
+
48
+ // decideIntegrationGate — PURE. `runs` is list-workflow-runs' `workflow_runs` for one
49
+ // head sha and the push event. The newest run decides (a re-push of the same sha, or a
50
+ // re-run, supersedes an older verdict).
51
+ function decideIntegrationGate(runs) {
52
+ const list = Array.isArray(runs) ? runs.filter((r) => r && typeof r === 'object') : [];
53
+ if (!list.length) return { verdict: 'wait', run: null };
54
+ const newest = list.slice().sort((a, b) =>
55
+ (Number(b.run_number) || 0) - (Number(a.run_number) || 0)
56
+ || String(b.created_at || '').localeCompare(String(a.created_at || '')))[0];
57
+ if (newest.status !== 'completed') return { verdict: 'wait', run: newest };
58
+ if (newest.conclusion === 'success') return { verdict: 'go', run: newest };
59
+ if (newest.conclusion === 'cancelled') return { verdict: 'superseded', run: newest };
60
+ return { verdict: 'red', run: newest };
61
+ }
62
+
63
+ function runsUrl(repo, sha) {
64
+ return `https://api.github.com/repos/${repo}/actions/workflows/${WORKFLOW_FILE}/runs?head_sha=${sha}&event=push&per_page=20`;
65
+ }
66
+
67
+ // waitForIntegration — poll until the gate has a verdict or the time is up. A 401/403/404
68
+ // is not something waiting fixes (a missing `actions: read`, a renamed workflow), so it
69
+ // ends the wait at once as 'unreadable'. Any other failed read is retried. Everything that
70
+ // touches the world is injectable for tests/publish_integration_gate.mjs.
71
+ async function waitForIntegration({
72
+ sha, repo, token, timeoutMs, intervalMs,
73
+ fetchImpl = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now,
74
+ }) {
75
+ const deadline = now() + timeoutMs;
76
+ let last = { verdict: 'wait', run: null, why: 'no read yet' };
77
+ for (;;) {
78
+ try {
79
+ const res = await fetchImpl(runsUrl(repo, sha), {
80
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' },
81
+ });
82
+ if ([401, 403, 404].includes(res.status)) return { verdict: 'unreadable', run: null, why: `HTTP ${res.status}` };
83
+ if (res.ok) {
84
+ const body = await res.json();
85
+ last = { ...decideIntegrationGate(body && body.workflow_runs), why: null };
86
+ if (last.verdict !== 'wait') return last;
87
+ } else {
88
+ last = { ...last, why: `HTTP ${res.status}` };
89
+ }
90
+ } catch (e) {
91
+ last = { ...last, why: (e && e.message) || String(e) };
92
+ }
93
+ if (now() + intervalMs > deadline) return { verdict: 'timeout', run: last.run, why: last.why };
94
+ await sleep(intervalMs);
95
+ }
96
+ }
97
+
98
+ // The one line per outcome the Actions log shows, and what the rest of the lane reads.
99
+ function reportLines(verdict, { sha, run, why, timeoutMin }) {
100
+ const short = String(sha).slice(0, 8);
101
+ const url = run && run.html_url ? ` ${run.html_url}` : '';
102
+ switch (verdict) {
103
+ case 'go':
104
+ return { go: true, code: 0, line: `integration is green on ${short}${url} — publishing.` };
105
+ case 'superseded':
106
+ return { go: false, code: 0, line: `::notice title=not published — integration was cancelled on ${short}::Usually a newer merge's run replaced it; that merge fires its own publish, which checks its own commit.${url}` };
107
+ case 'red':
108
+ return { go: false, code: 1, line: `::error title=not published — integration is red on ${short}::The integration lane (${run && run.conclusion}) runs the proof that the packaged core can migrate an empty database (task 1002863). A core that fails it must not reach npm. Fix main; the next merge publishes everything.${url}` };
109
+ case 'unreadable':
110
+ return { go: false, code: 1, line: `::error title=not published — cannot read the integration lane::${why}. publish.yml needs 'permissions: actions: read', and the workflow file must still be ${WORKFLOW_FILE}.` };
111
+ default:
112
+ return { go: false, code: 1, line: `::error title=not published — integration had no verdict on ${short} after ${timeoutMin} min::${why ? `Last read: ${why}. ` : ''}Re-run this workflow once integration finishes, or let the next merge publish.${url}` };
113
+ }
114
+ }
115
+
116
+ function argValue(argv, flag) {
117
+ const i = argv.indexOf(flag);
118
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
119
+ }
120
+
121
+ async function main(argv = process.argv.slice(2), env = process.env) {
122
+ const sha = String(argValue(argv, '--sha') || '').toLowerCase();
123
+ const repo = String(argValue(argv, '--repo') || env.GITHUB_REPOSITORY || '');
124
+ const token = env.GH_TOKEN || env.GITHUB_TOKEN || '';
125
+ const timeoutMin = Number(argValue(argv, '--timeout-min') || DEFAULT_TIMEOUT_MIN);
126
+ const intervalS = Number(argValue(argv, '--interval-s') || DEFAULT_INTERVAL_S);
127
+ if (!SHA_RE.test(sha) || !REPO_RE.test(repo) || !token || !(timeoutMin > 0) || !(intervalS > 0)) {
128
+ console.error('usage: publish-integration-gate.js --sha <40-hex> --repo <owner/name> [--timeout-min N] [--interval-s N] (token: GH_TOKEN or GITHUB_TOKEN)');
129
+ return 2;
130
+ }
131
+ const result = await waitForIntegration({ sha, repo, token, timeoutMs: timeoutMin * 60000, intervalMs: intervalS * 1000 });
132
+ const out = reportLines(result.verdict, { sha, run: result.run, why: result.why, timeoutMin });
133
+ console.log(out.line);
134
+ if (env.GITHUB_OUTPUT) fs.appendFileSync(env.GITHUB_OUTPUT, `go=${out.go}\n`);
135
+ return out.code;
136
+ }
137
+
138
+ if (require.main === module) {
139
+ main().then((code) => { process.exitCode = code; }, (e) => { console.error(e); process.exitCode = 1; });
140
+ }
141
+
142
+ module.exports = { decideIntegrationGate, waitForIntegration, reportLines, main, WORKFLOW_FILE, runsUrl };
@@ -291,7 +291,17 @@ function strandNextStep(taskId, via) {
291
291
  // task 1003764: `red` — the PR was RED on the poll's last read. "NOT a failure" is then
292
292
  // false: a required check that failed does not pass by waiting, so the banner says the
293
293
  // land is stuck on it and defers to the red lines above for the remedy.
294
- function landPendingLines(taskId, { merged, red } = {}) {
294
+ function landPendingLines(taskId, { merged, red, notStarted } = {}) {
295
+ // task 1004289: CI never RAN (the account cannot run Actions). Not a failure of the
296
+ // work, and not something waiting fixes either: the checks have to be started again.
297
+ if (notStarted) {
298
+ return [
299
+ ' ci-land: Published, but CI never started on the PR (GitHub ran none of its checks), so it has NOT merged',
300
+ ' and cannot until they run. The task stays at \'confirmed\'. Nothing in the work needs to change: once the',
301
+ ` owner restores GitHub Actions, re-run the failed jobs (lines above), then re-run \`node scripts/gds/ship.js ${taskId}\``,
302
+ ' to watch the land — it resumes from here (task 941).',
303
+ ];
304
+ }
295
305
  if (red) {
296
306
  return [
297
307
  ' ci-land: Published, but the PR is RED (the check named above), so it has NOT merged and will not',
@@ -314,8 +324,9 @@ function printLandPending(taskId, opts = {}) {
314
324
  console.log('');
315
325
  for (const line of landPendingLines(taskId, opts)) console.log(line);
316
326
  // The land_pending card says there is nothing to do, which a red PR makes untrue; the
317
- // red line already printed the failing_check card (task 1003764).
318
- if (!opts.red) for (const line of plainCardLines('land_pending')) console.log(line);
327
+ // red line already printed the failing_check card (task 1003764), or the
328
+ // ci_not_started one (task 1004289).
329
+ if (!opts.red && !opts.notStarted) for (const line of plainCardLines('land_pending')) console.log(line);
319
330
  }
320
331
 
321
332
  // task 1002645 — the honest close-out when the PR MERGED but the server refused the
@@ -351,6 +362,11 @@ function prAssigneeLine(login) {
351
362
  // exported so the wording is pinned by tests/unit_report_status.mjs.
352
363
  function redPrLine(md) {
353
364
  const reason = (md && md.reason) || '';
365
+ if (md && md.not_started) {
366
+ const job = (/^ci_not_started:(.+)$/.exec(reason) || [])[1];
367
+ return ` ci-land: CI NEVER RAN on this PR — GitHub did not start ${job ? `the \`${job}\` job` : 'its checks'} ("The job was not started because …", `
368
+ + 'an Actions budget or billing stop on the account). Nothing in the work was tested, so there is nothing in it to fix.';
369
+ }
354
370
  const fix = 'fix it, commit, and re-run the ship — re-pushing unchanged work cannot turn it green.';
355
371
  if (md && md.failing_tests) {
356
372
  return ` ci-land: the PR is RED — ${md.failing_tests}. Reproduce with \`node tests/<name>.mjs\`, ${fix}`;
@@ -369,9 +385,21 @@ function redPrLine(md) {
369
385
  // task 1003764: the merge trap sits between them. `opts` = { taskId, via } for its wording.
370
386
  function redPrLines(md, opts = {}) {
371
387
  const red = redPrLine(md);
388
+ if (red && md.not_started) return [red, ...notStartedLines(md), ...plainCardLines('ci_not_started')];
372
389
  return red ? [red, ...mergeTrapLines(md, opts), ...plainCardLines((md && md.reason) || 'failing_check')] : [];
373
390
  }
374
391
 
392
+ // notStartedLines — task 1004289. What to do when CI never ran, instead of the merge trap
393
+ // (there is no failure to reproduce). Re-running is the remedy, not re-pushing: the same
394
+ // commit is fine, it simply was never tested. Pure + exported.
395
+ function notStartedLines(md) {
396
+ const rerun = md && /^\d+$/.test(String(md.run_id || '')) ? `\`gh run rerun ${md.run_id} --failed\`` : '"Re-run failed jobs" on the PR';
397
+ return [
398
+ ' ci-land: this is the repo OWNER\'s to fix, on GitHub: the account\'s Actions budget / spending limit or payment method.',
399
+ ` ci-land: once Actions can run again, ${rerun} starts the checks on the SAME commit — do not re-push or edit the work.`,
400
+ ];
401
+ }
402
+
375
403
  // The unit tests that gate GENERATED files — each compares a committed artifact with a
376
404
  // fresh build of it. `fitness` holds the combined gate (fitness.js "generated artifacts
377
405
  // fresh": copy registry, API docs, file map, nested CLAUDE.md blocks, the §13 snippet).
@@ -439,16 +467,42 @@ const RED_CONCLUSIONS = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', '
439
467
  // poll paths hand redPrLines the same thing. `failingTestsFor(sha)` reads the unit-report
440
468
  // status (gh pr view omits a status's description) and is asked only once that status is
441
469
  // itself red, i.e. posted. Null when nothing is red. Pure + exported.
442
- function localMergeDriver(pr, failingTestsFor = () => null) {
470
+ // task 1004289 — `neverStarted(rawCheck)` answers "did this red job never run at all?"
471
+ // (jobNeverStarted below). When EVERY red check is one, nothing was tested, and the
472
+ // answer is { reason: 'ci_not_started:<name>', not_started: true, run_id } rather than
473
+ // a failing check the builder is told to fix.
474
+ function localMergeDriver(pr, failingTestsFor = () => null, neverStarted = () => false) {
443
475
  if (!pr || pr.state !== 'OPEN' || !Array.isArray(pr.statusCheckRollup)) return null;
444
476
  const { checks } = readCheckRollup({ contexts: { nodes: pr.statusCheckRollup } });
445
477
  const red = checks.filter((c) => RED_CONCLUSIONS.has(String(c.conclusion || '').toUpperCase()));
446
478
  if (!red.length) return null;
479
+ const redRaw = pr.statusCheckRollup.filter((c) => c && RED_CONCLUSIONS.has(String(c.conclusion || c.state || '').toUpperCase()));
480
+ if (redRaw.length && redRaw.every((c) => neverStarted(c))) {
481
+ const run = /\/runs\/(\d+)\//.exec(String(redRaw[0].detailsUrl || ''));
482
+ return { reason: `ci_not_started:${red[0].name}`, not_started: true, failing_tests: null, run_id: run ? run[1] : null };
483
+ }
447
484
  const kind = pr.mergeStateStatus === 'UNSTABLE' ? 'non_required_check_failing' : 'failing_check';
448
485
  const reported = red.some((c) => c.name === UNIT_REPORT_CONTEXT);
449
486
  return { reason: `${kind}:${red[0].name}`, failing_tests: (reported && failingTestsFor(pr.headRefOid)) || null };
450
487
  }
451
488
 
489
+ // jobNeverStarted — task 1004289. When the account cannot run Actions (its budget is
490
+ // spent, or a payment failed), GitHub still marks every job FAILED seconds after it was
491
+ // queued, with no steps and no runner, and one annotation saying why: "The job was not
492
+ // started because an Actions budget is preventing further use." (PR 1211, 2026-09-25).
493
+ // Read as an ordinary red check, that told the builder to fix their code — advice that
494
+ // cannot help, where each retry is another failed run. An Actions job's id (the tail of
495
+ // its detailsUrl) is its check-run id, so its annotations are one read away. FAIL-OPEN:
496
+ // anything unreadable is "no", which keeps the ordinary red wording. `gh` is injectable.
497
+ const NOT_STARTED_RE = /^\s*The job was not started because\b/i;
498
+ function jobNeverStarted(check, gh = ghJson) {
499
+ if (!check || (check.__typename && check.__typename !== 'CheckRun')) return false;
500
+ const m = /\/job\/(\d+)(?:[/?#]|$)/.exec(String(check.detailsUrl || ''));
501
+ if (!m) return false;
502
+ const notes = gh(['api', `repos/{owner}/{repo}/check-runs/${m[1]}/annotations`]);
503
+ return Array.isArray(notes) && notes.some((a) => a && NOT_STARTED_RE.test(String(a.message || '')));
504
+ }
505
+
452
506
  // unitReportFor — the failing-test line the `unit` workflow posted on `sha`, read with this
453
507
  // machine's gh through the same parser the server uses. Null when unreadable; the red line
454
508
  // then names the check instead, as it did before the report existed. `gh` is injectable.
@@ -921,6 +975,14 @@ function pollCiDeploy(taskId, branch, headSha, deps = {}) {
921
975
  if (sha !== report.sha) report = { sha, text: unitReportFor(sha, readJson) };
922
976
  return report.text;
923
977
  };
978
+ // task 1004289: a finished job's annotations never change, so each is read once.
979
+ let notStarted = false;
980
+ const startMemo = new Map();
981
+ const neverStarted = (c) => {
982
+ const key = String((c && c.detailsUrl) || '');
983
+ if (!startMemo.has(key)) startMemo.set(key, jobNeverStarted(c, readJson));
984
+ return startMemo.get(key);
985
+ };
924
986
  for (let i = 0; i < MERGE_TRIES; i++) {
925
987
  // task 1003499: mergeable + mergeStateStatus ride the same read, so a conflict is
926
988
  // named on the poll it appears; the two-field read is the fallback for a gh that
@@ -937,13 +999,14 @@ function pollCiDeploy(taskId, branch, headSha, deps = {}) {
937
999
  return landBailed('the PR was closed without merging (a check failed, or it was closed manually)', `node scripts/gds/ship.js ${taskId}`);
938
1000
  }
939
1001
  if (pr) {
940
- const md = localMergeDriver(pr, failingTestsFor);
1002
+ const md = localMergeDriver(pr, failingTestsFor, neverStarted);
941
1003
  const red = redPrLine(md);
942
1004
  if (red && red !== redLine) {
943
1005
  redLine = red;
944
1006
  for (const line of redPrLines(md, { taskId, via: 'local' })) console.log(line);
945
1007
  }
946
1008
  stuckRed = !!red && redBlocksMerge(md);
1009
+ notStarted = !!(md && md.not_started);
947
1010
  }
948
1011
  conflicting = noteConflict(taskId, pr, conflicting);
949
1012
  pause();
@@ -954,7 +1017,7 @@ function pollCiDeploy(taskId, branch, headSha, deps = {}) {
954
1017
  // CONFLICTING (task 1003499): then nothing is still in CI, and it never merges.
955
1018
  // Or RED (task 1003764): then the banner says the land is stuck on that check.
956
1019
  if (conflicting) return landConflicted(taskId);
957
- printLandPending(taskId, { red: stuckRed });
1020
+ printLandPending(taskId, { red: stuckRed, notStarted });
958
1021
  return landPending();
959
1022
  }
960
1023
  console.log(` ci-land: merged to main (${mergeSha.slice(0, 8)}). Waiting for the prod deploy...`);
@@ -1036,6 +1099,9 @@ module.exports = {
1036
1099
  redBlocksMerge,
1037
1100
  localMergeDriver,
1038
1101
  unitReportFor,
1102
+ // task 1004289: CI that never ran is said as such, not as a failure to fix.
1103
+ jobNeverStarted,
1104
+ notStartedLines,
1039
1105
  reconciledCardStatus,
1040
1106
  strandCommand,
1041
1107
  strandNextStep,
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.1037'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1039'; // 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');
@@ -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