@bongos/core 1.19.383 → 1.19.385

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.
@@ -70,6 +70,35 @@ const WORKER_MAX_TIMEOUT_MS = {
70
70
  efficiency: 360_000, // Read-heavy on a large diff: traces perf across every changed file
71
71
  };
72
72
 
73
+ // task 1003313: the diagnostic a dark panel used to throw away.
74
+ //
75
+ // runSubagent attaches exit_code + stdout + stderr to every subprocess error,
76
+ // but the catch below kept only err.code — so a TOTAL outage stored four
77
+ // identical `SUBAGENT_NONZERO_EXIT` rows and nothing else, and two builders sat
78
+ // dark for days with a 0/10 card that could not name its own cause. At least two
79
+ // known environmental faults produce that exact signature (a logged-out `claude`
80
+ // CLI — preflight.js claudeAuthFinding; the Windows WindowsApps alias stub —
81
+ // learning 42) and the stored grade could not tell them apart.
82
+ //
83
+ // The tail is REDACTED and BOUNDED because it lands in a grade row that the hall
84
+ // renders: CLI stderr can carry an auth header or a key echoed back in an error.
85
+ const STDERR_TAIL_MAX = 400;
86
+ // eslint-disable-next-line no-control-regex -- stripping ANSI SGR from CLI stderr
87
+ const ANSI_RE = /\x1B\[[0-9;]*[A-Za-z]/g;
88
+ const SECRET_RES = [
89
+ /sk-ant-[A-Za-z0-9_-]+/g,
90
+ /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g,
91
+ /github_pat_[A-Za-z0-9_]{20,}/g,
92
+ /\b[Bb]earer\s+[A-Za-z0-9._~+/-]{12,}=*/g,
93
+ ];
94
+ function redactStderrTail(raw) {
95
+ let s = String(raw || '').replace(ANSI_RE, '').trim();
96
+ if (!s) return null;
97
+ for (const re of SECRET_RES) s = s.replace(re, '[redacted]');
98
+ s = s.slice(-STDERR_TAIL_MAX);
99
+ return s || null;
100
+ }
101
+
73
102
  // Resolve the timeout for a worker: explicit module override > KIND map >
74
103
  // caller's runOpts > undefined (runSubagent applies its own default).
75
104
  function timeoutForWorker(worker, runOpts) {
@@ -127,6 +156,9 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
127
156
  let runMs = null;
128
157
  let costUsd = null;
129
158
  let subprocessError = null;
159
+ // task 1003313: the WHY behind subprocessError. Null on a clean run.
160
+ let exitCode = null;
161
+ let stderrTail = null;
130
162
  const t0 = Date.now();
131
163
  try {
132
164
  const { stdout, run_ms, cost_usd } = await runSubagentFn({ prompt, opts: { ...runOpts, model, timeoutMs } });
@@ -139,6 +171,11 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
139
171
  } catch (err) {
140
172
  runMs = err.run_ms ?? (Date.now() - t0);
141
173
  subprocessError = err.code || 'UNKNOWN';
174
+ exitCode = Number.isInteger(err.exit_code) ? err.exit_code : null;
175
+ // A CLI that dies before writing JSON often says why on stdout, not stderr
176
+ // (`claude` prints its "not logged in" line there) — fall back so the tail
177
+ // is never empty when the subprocess did explain itself.
178
+ stderrTail = redactStderrTail(err.stderr) ?? redactStderrTail(err.stdout);
142
179
  }
143
180
  const parsed = subprocessError
144
181
  ? null
@@ -148,10 +185,10 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
148
185
  run_ms: runMs,
149
186
  subprocess_error: subprocessError,
150
187
  });
151
- return { verdict, runMs, costUsd };
188
+ return { verdict, runMs, costUsd, exitCode, stderrTail };
152
189
  }
153
190
 
154
- let { verdict, runMs, costUsd } = await attempt();
191
+ let { verdict, runMs, costUsd, exitCode, stderrTail } = await attempt();
155
192
  // #465: retry ONCE on a transient error (the freshest attempt wins, even if it
156
193
  // also errored — one retry only). A real verdict or a SUBAGENT_TIMEOUT is
157
194
  // never retried. NB (#486): the freshest attempt's cost wins; a transient
@@ -159,7 +196,7 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
159
196
  let retried = false;
160
197
  if (TRANSIENT_WORKER_ERRORS.has(verdict.error)) {
161
198
  retried = true;
162
- ({ verdict, runMs, costUsd } = await attempt());
199
+ ({ verdict, runMs, costUsd, exitCode, stderrTail } = await attempt());
163
200
  }
164
201
 
165
202
  return {
@@ -176,6 +213,10 @@ async function runWorker(worker, { task, valueSummary, diff, changedFiles, diffS
176
213
  cost_usd: costUsd, // #486: this worker's own subprocess API spend (USD)
177
214
  retried,
178
215
  error: verdict.error ?? null,
216
+ // task 1003313: on an errored worker, WHY. Omitted entirely on a clean run so
217
+ // a healthy grade row keeps its existing shape.
218
+ ...(verdict.error && exitCode != null ? { exit_code: exitCode } : {}),
219
+ ...(verdict.error && stderrTail ? { stderr_tail: stderrTail } : {}),
179
220
  // Narc-specific (carried opaquely; aggregatePanel doesn't need to know):
180
221
  ...(verdict.ac_checklist ? { ac_checklist: verdict.ac_checklist } : {}),
181
222
  };
@@ -188,6 +229,8 @@ module.exports = {
188
229
  runWorker,
189
230
  timeoutForWorker, // public surface; WORKER_MAX_TIMEOUT_MS stays module-internal
190
231
  TRANSIENT_WORKER_ERRORS,
232
+ redactStderrTail, // task 1003313: exported so the redaction contract is pinned
233
+ STDERR_TAIL_MAX,
191
234
  // re-export per-worker modules for direct access by tests
192
235
  narc,
193
236
  quality,
@@ -88,6 +88,27 @@ const { buildPrompt, extractJson } = require('./grader-prompt.js');
88
88
  const { aggregateShots, changeMagnitude, hasPanelEvidence, isSubstantiveIssue, serverSideGuards, validateAndScore } = require('./grader-score.js');
89
89
  const { buildContextBundle, diffAddedLinesByFile, extractAssistantResult, extractCostUsd, runOneShot, runSubagent, runSubagentCached } = require('./grader-subagent.js');
90
90
 
91
+ // task 1003313: render the WHY of a total outage into the verdict line.
92
+ //
93
+ // Exists because "GRADER UNAVAILABLE — all 4 workers failed to run" is not a
94
+ // diagnosis: a logged-out `claude` CLI, a Windows app-execution-alias stub, and a
95
+ // genuine API outage all produce it, and the builder sees no way to tell which.
96
+ // runWorker now carries exit_code + a redacted stderr_tail off each dead worker;
97
+ // this puts the shared exit code and the first distinct CLI error line into the
98
+ // text the builder actually reads. Silent when the workers carry no detail
99
+ // (a grade from a server predating the propagation).
100
+ const OUTAGE_TAIL_MAX = 300;
101
+ function describeOutage(gating) {
102
+ const dead = gating.filter((w) => w.error);
103
+ const codes = [...new Set(dead.map((w) => w.exit_code).filter((c) => c != null))];
104
+ const tails = [...new Set(dead.map((w) => w.stderr_tail).filter(Boolean))];
105
+ if (!codes.length && !tails.length) return '';
106
+ const parts = [];
107
+ if (codes.length) parts.push(`exit ${codes.join('/')}`);
108
+ if (tails.length) parts.push(`subprocess said: ${tails[0].slice(-OUTAGE_TAIL_MAX)}`);
109
+ return ` Diagnostic — ${parts.join('; ')}`;
110
+ }
111
+
91
112
  function aggregatePanel(workerResults, meta = {}) {
92
113
  const N = workerResults.length;
93
114
 
@@ -232,6 +253,10 @@ function aggregatePanel(workerResults, meta = {}) {
232
253
  const erroredKinds = gating.filter((w) => w.error).map((w) => w.kind);
233
254
  verdict =
234
255
  `GRADER UNAVAILABLE — all ${gatingError}/${gating.length} gating worker(s) failed to run (${erroredKinds.join(', ')}); no quality signal was produced. This is an infra/outage condition, NOT a quality rejection — manual-confirm available.` +
256
+ // task 1003313: name the cause. Without this the card said only "failed to
257
+ // run" four times over, and two builders sat dark for days because nothing
258
+ // stored — or showed — the exit code and the CLI's own error line.
259
+ describeOutage(gating) +
235
260
  advisoryNote;
236
261
  panelOutcome = 'unavailable';
237
262
  forwardedConfidence = FALLTHROUGH_CONFIDENCE;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.383",
3
+ "version": "1.19.385",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.383",
9
+ "version": "1.19.385",
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.383",
3
+ "version": "1.19.385",
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",
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // adr-namespace.js — ADR numbering discipline (task 1003164). The ADR half of the guard
4
+ // `migration-namespace.js` already gives migrations. Pure helpers over file lists + index
5
+ // text, plus the fitness check that reads the tree.
6
+ //
7
+ // PROBLEM. An ADR number is a CITATION TARGET. ADRs cite each other by number, prose and
8
+ // task notes cite them by number, and `docs/adr/README.md` is the index every agent is
9
+ // told to consult. Two files can carry the same number and NOTHING notices: the filenames
10
+ // differ (`0181-ship-requires-an-assurance.md` vs
11
+ // `0181-teardown-state-gate-and-address-retention.md`), so there is no path collision, no
12
+ // merge conflict, and git adds both cleanly. README.md auto-merges too, ending up with two
13
+ // `| 0181 |` rows. Unit, fitness, docs-entropy and the grader all passed; it was found on
14
+ // 2026-08-22 only because a human read `ls docs/adr/`. By then task 1000900's
15
+ // close-rationale had already cited "ADR 0180", which the collision had made point at an
16
+ // unrelated document — a citation that is wrong forever.
17
+ //
18
+ // THE ASYMMETRY THIS CLOSES. Migrations have exactly this protection already: a re-used
19
+ // migration number silently SKIPS a migration, so `stemCollisions()` hard-fails CI. ADRs
20
+ // got `next-number.js` — which computes the next free number at claim time, on a
21
+ // possibly-stale tree, best-effort — and then nothing enforced it afterwards. Same shared,
22
+ // monotonic, human-allocated number line; one guarded, one not. A stale claim card, two
23
+ // builders claiming the same day, or a branch that sits long enough all reproduce it.
24
+ //
25
+ // WHY GRANDFATHER RATHER THAN RENUMBER. When this landed the live tree already held TWENTY
26
+ // colliding numbers, not the one the bug report found. Renumbering nineteen pairs would
27
+ // break every existing citation to them — which is the very harm this check exists to
28
+ // prevent — and README.md states the rule outright: a number, once assigned, is never
29
+ // reused. So the existing pairs are frozen into LEGACY_DUPLICATE_ADRS by their EXACT
30
+ // filenames (the shape migration-namespace.js uses for its own grandfathering, and for
31
+ // RENAMED_MODULE_PREFIXES). Frozen by exact filename, not by number, is the load-bearing
32
+ // part: a THIRD file joining a legacy number still fails, and so does a rename of either
33
+ // half. The list can only shrink. See ADR 0195.
34
+ //
35
+ // The index half is checked by LINK TARGET, not by number, because a number rule alone
36
+ // cannot express the grandfathered pairs: the invariant is that every ADR file has exactly
37
+ // one row and every row points at a file that exists. That catches the dangling row AND
38
+ // the duplicated row AND the file nobody indexed — the last of which is how a collision
39
+ // actually hides, since the second file of a colliding pair is the one that never gets a
40
+ // row.
41
+
42
+ const fs = require('node:fs');
43
+ const path = require('node:path');
44
+ const { CORE_ROOT } = require('./fitness-lib.js');
45
+
46
+ // ---- parsing ---------------------------------------------------------------
47
+
48
+ // `NNNN-kebab-title.md` — the naming rule in docs/adr/README.md §File naming.
49
+ const ADR_RE = /^(\d{4})-(.+)\.md$/;
50
+
51
+ // Parse an ADR basename into { number, num, slug, ok }. `number` is the 4-digit STRING
52
+ // (the citation form, zero-padded); `num` is the integer for ordering/allocation. A
53
+ // non-ADR name (README.md, a draft) returns { ok: false }.
54
+ function parseAdrName(basename) {
55
+ const m = ADR_RE.exec(String(basename == null ? '' : basename));
56
+ if (!m) return { number: null, num: null, slug: null, ok: false };
57
+ return { number: m[1], num: parseInt(m[1], 10), slug: m[2], ok: true };
58
+ }
59
+
60
+ // The 4-digit number string of an ADR basename, or null if it is not an ADR.
61
+ function adrNumberOf(basename) {
62
+ const p = parseAdrName(basename);
63
+ return p.ok ? p.number : null;
64
+ }
65
+
66
+ // ---- grandfathered collisions ---------------------------------------------
67
+
68
+ // Numbers already shared by two files when this check landed (task 1003164, 2026-08-27).
69
+ // Frozen by EXACT filename: a third file on one of these numbers, or a rename of either
70
+ // half, is still a hard failure. This list may shrink (if a pair is ever consolidated) and
71
+ // must never grow — a new collision is the bug the check exists to catch.
72
+ const LEGACY_DUPLICATE_ADRS = Object.freeze({
73
+ '0008': Object.freeze(['0008-family-based-tile-generation.md', '0008-google-chat-oauth-user-auth.md']),
74
+ '0024': Object.freeze(['0024-cloneable-repo-local-first-memory.md', '0024-multi-agent-system-architecture.md']),
75
+ '0025': Object.freeze(['0025-offsite-backup-vendor-digitalocean-spaces.md', '0025-structured-criterion-task-link.md']),
76
+ '0044': Object.freeze(['0044-mediterranean-palette-replacement.md', '0044-per-box-live-game-preview.md']),
77
+ '0057': Object.freeze(['0057-container-cost-ledger.md', '0057-discord-archon-approval-channels.md']),
78
+ '0072': Object.freeze(['0072-bongos-app-mac-signed-first-windows-deferred.md', '0072-dev-box-code-staleness-visibility.md']),
79
+ '0073': Object.freeze(['0073-builder-needs-signal-and-byok-gemini-key.md', '0073-secrets-scan-exclude-uri-detector.md']),
80
+ '0082': Object.freeze(['0082-productized-key-storage-provisioning.md', '0082-server-side-merge-conflict-auto-resolution.md']),
81
+ '0087': Object.freeze(['0087-bongos-app-architecture-and-handoff-contract.md', '0087-compete-on-governance-not-tooling-cursor.md']),
82
+ '0095': Object.freeze(['0095-borrowed-memory-and-retrieval-concepts.md', '0095-cross-agent-context-management.md']),
83
+ '0097': Object.freeze(['0097-one-active-claim-per-session-and-worktree-binding.md', '0097-retroactive-reward-backfill.md']),
84
+ '0103': Object.freeze(['0103-core-first-extraction-cloud-bongos-trunk.md', '0103-gdsv4-bongos-consolidation.md']),
85
+ '0144': Object.freeze(['0144-devbox-rehome-onto-cloudbongos-plane.md', '0144-federated-single-logout-backchannel.md']),
86
+ '0145': Object.freeze(['0145-devbox-app-branding-driven-module.md', '0145-free-hosted-project-tier-isolation-and-domain-separation.md']),
87
+ '0152': Object.freeze(['0152-landing-gate-served-by-the-core-app.md', '0152-metic-task-abandonment.md']),
88
+ '0167': Object.freeze(['0167-gate-trust-link-agpl-default.md', '0167-module-catalog-source-vs-provenance.md']),
89
+ '0172': Object.freeze(['0172-editable-rank-roles-substrate-axis.md', '0172-per-craft-compensation-ideator-credit-lane.md']),
90
+ '0184': Object.freeze(['0184-a-task-visual-inherits-its-summary-audience.md', '0184-ship-requires-an-assurance.md']),
91
+ '0187': Object.freeze(['0187-collab-four-decisions.md', '0187-oauth-handshake-cookie-lifetime.md']),
92
+ '0192': Object.freeze(['0192-a-category-orients-and-authorises-nothing.md', '0192-platform-visibility-member-door.md']),
93
+ });
94
+
95
+ // Is this exact set of files the frozen legacy pair for that number? Set-equality, so
96
+ // adding a third file or renaming either half stops being grandfathered.
97
+ function isGrandfathered(number, files) {
98
+ const legacy = LEGACY_DUPLICATE_ADRS[number];
99
+ if (!legacy) return false;
100
+ if (files.length !== legacy.length) return false;
101
+ const a = [...files].sort();
102
+ const b = [...legacy].sort();
103
+ return a.every((f, i) => f === b[i]);
104
+ }
105
+
106
+ // ---- collision detector ----------------------------------------------------
107
+
108
+ // Given ADR basenames, return [{ number, files[] }] for every number claimed by more than
109
+ // one file, EXCLUDING the frozen legacy pairs. The direct analogue of
110
+ // migration-namespace.js stemCollisions().
111
+ function numberCollisions(basenames) {
112
+ const byNumber = new Map();
113
+ for (const name of basenames || []) {
114
+ const p = parseAdrName(name);
115
+ if (!p.ok) continue;
116
+ if (!byNumber.has(p.number)) byNumber.set(p.number, new Set());
117
+ byNumber.get(p.number).add(name);
118
+ }
119
+ const out = [];
120
+ for (const [number, set] of byNumber) {
121
+ const files = [...set].sort();
122
+ if (files.length > 1 && !isGrandfathered(number, files)) out.push({ number, files });
123
+ }
124
+ return out.sort((a, b) => a.number.localeCompare(b.number));
125
+ }
126
+
127
+ // The next free ADR number (integer): max + 1, 1 on an empty tree. Mirrors next-number.js
128
+ // nextFree so a caller can allocate without reaching for git.
129
+ function nextAdrNumber(basenames) {
130
+ let max = 0;
131
+ for (const name of basenames || []) {
132
+ const p = parseAdrName(name);
133
+ if (p.ok && p.num > max) max = p.num;
134
+ }
135
+ return max + 1;
136
+ }
137
+
138
+ // ---- index (docs/adr/README.md) --------------------------------------------
139
+
140
+ // An index row: `| NNNN | [Title](NNNN-slug.md) | topic |`. The leading cell may carry a
141
+ // disambiguating suffix (`0008-a`) for a grandfathered pair, so the number is read from
142
+ // the leading cell and the row's OWN file from the link target that starts with it — row
143
+ // prose cites other ADRs by link too, and only the own-number target is the row's subject.
144
+ const INDEX_ROW_RE = /^\|\s*(\d{4})(?:-[a-z])?\s*\|/;
145
+ const INDEX_LINK_RE = /\]\((\d{4}-[^)]*\.md)\)/g;
146
+
147
+ // Parse README text into [{ number, target, targets, lineNo, ok }]. `target` is the row's
148
+ // own ADR filename, or null when the row names none (or names more than one of its own
149
+ // number, which is ambiguous and reported as a violation).
150
+ function parseIndexRows(readmeText) {
151
+ const rows = [];
152
+ const lines = String(readmeText == null ? '' : readmeText).split('\n');
153
+ lines.forEach((line, i) => {
154
+ const m = INDEX_ROW_RE.exec(line);
155
+ if (!m) return;
156
+ const number = m[1];
157
+ INDEX_LINK_RE.lastIndex = 0;
158
+ const own = [...line.matchAll(INDEX_LINK_RE)].map((x) => x[1]).filter((t) => t.startsWith(number + '-'));
159
+ rows.push({ number, target: own.length === 1 ? own[0] : null, targets: own, lineNo: i + 1, ok: own.length === 1 });
160
+ });
161
+ return rows;
162
+ }
163
+
164
+ // The index invariant, checked by LINK TARGET: every ADR file has exactly one row, and
165
+ // every row points at a file that exists. Returns violation strings.
166
+ function indexViolations({ basenames = [], rows = [] } = {}) {
167
+ const violations = [];
168
+ const files = new Set((basenames || []).filter((n) => parseAdrName(n).ok));
169
+
170
+ const rowsByTarget = new Map();
171
+ for (const r of rows) {
172
+ if (!r.ok) {
173
+ violations.push(
174
+ `docs/adr/README.md:${r.lineNo}: the index row for ${r.number} names ${r.targets.length} link(s) to its own number `
175
+ + `(${r.targets.join(', ') || 'none'}) — a row must link its own ADR file exactly once so the index can be checked against the tree.`);
176
+ continue;
177
+ }
178
+ if (!rowsByTarget.has(r.target)) rowsByTarget.set(r.target, []);
179
+ rowsByTarget.get(r.target).push(r.lineNo);
180
+ }
181
+
182
+ for (const [target, lineNos] of [...rowsByTarget.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
183
+ if (!files.has(target)) {
184
+ violations.push(
185
+ `docs/adr/README.md:${lineNos[0]}: index row points at docs/adr/${target}, which does not exist — a dangling index row `
186
+ + `(the other half of the collision problem: a merge can leave a row behind after the file it named moved or was renumbered).`);
187
+ }
188
+ if (lineNos.length > 1) {
189
+ violations.push(
190
+ `docs/adr/README.md: ${target} has ${lineNos.length} index rows (lines ${lineNos.join(', ')}) — exactly one row per ADR; `
191
+ + 'a duplicated row is what an auto-merged index leaves behind.');
192
+ }
193
+ }
194
+
195
+ for (const f of [...files].sort()) {
196
+ if (!rowsByTarget.has(f)) {
197
+ violations.push(
198
+ `docs/adr/${f} has no row in docs/adr/README.md — every ADR is indexed. (This is how a number collision hides: the `
199
+ + 'second file of a colliding pair is the one that never gets indexed, so the tree and the index quietly disagree.)');
200
+ }
201
+ }
202
+
203
+ return violations;
204
+ }
205
+
206
+ // Rows should read in ascending number order. WARNING only, never a hard failure: the live
207
+ // index carries legacy rows an auto-merge placed out of order, and blocking on history
208
+ // would fail every PR for a mess nobody's diff created (the ADR 0189 split — declaration
209
+ // rot hard-fails, inherited drift warns).
210
+ function indexOrderWarnings(rows) {
211
+ const warnings = [];
212
+ let prev = null;
213
+ for (const r of rows) {
214
+ if (prev !== null && r.number < prev) {
215
+ warnings.push(`docs/adr/README.md:${r.lineNo}: row ${r.number} sits after ${prev} — the index reads in ascending order.`);
216
+ }
217
+ if (prev === null || r.number > prev) prev = r.number;
218
+ }
219
+ return warnings;
220
+ }
221
+
222
+ // ---- the fitness check -----------------------------------------------------
223
+
224
+ const ADR_DIR = path.join(CORE_ROOT, 'docs', 'adr');
225
+
226
+ function adrFilesIn(absDir) {
227
+ try { return fs.readdirSync(absDir).filter((f) => parseAdrName(f).ok).sort(); }
228
+ catch { return []; } // no docs/adr in a vanilla instance → nothing to check
229
+ }
230
+
231
+ // Check 31 — two ADRs may not share a number, and the index must agree with the tree.
232
+ function checkAdrNumbering() {
233
+ const files = adrFilesIn(ADR_DIR);
234
+ const violations = [];
235
+
236
+ for (const c of numberCollisions(files)) {
237
+ violations.push(
238
+ `ADR number ${c.number} is claimed by ${c.files.length} files (${c.files.map((f) => `docs/adr/${f}`).join(', ')}) — `
239
+ + 'an ADR number is a citation target, so a duplicate makes every citation to it ambiguous forever. Renumber the NEWER '
240
+ + 'file to the next free number (node scripts/gds/next-number.js) and move its README row with it.');
241
+ }
242
+
243
+ let readme = null;
244
+ try { readme = fs.readFileSync(path.join(ADR_DIR, 'README.md'), 'utf8'); } catch { /* no index → nothing to reconcile */ }
245
+ const rows = readme === null ? [] : parseIndexRows(readme);
246
+ if (readme !== null) violations.push(...indexViolations({ basenames: files, rows }));
247
+ const warnings = readme === null ? [] : indexOrderWarnings(rows);
248
+
249
+ return {
250
+ name: 'ADR numbers unique + index agrees with the tree (task 1003164)',
251
+ ok: violations.length === 0,
252
+ hardFail: violations.length > 0,
253
+ violations,
254
+ warnings,
255
+ note: `${files.length} ADR file(s), ${rows.length} index row(s), `
256
+ + `${Object.keys(LEGACY_DUPLICATE_ADRS).length} grandfathered duplicate number(s) (frozen by exact filename — the list may only shrink). `
257
+ + `Next free number: ${String(nextAdrNumber(files)).padStart(4, '0')}.`,
258
+ };
259
+ }
260
+
261
+ module.exports = {
262
+ ADR_RE,
263
+ LEGACY_DUPLICATE_ADRS,
264
+ parseAdrName,
265
+ adrNumberOf,
266
+ isGrandfathered,
267
+ numberCollisions,
268
+ nextAdrNumber,
269
+ parseIndexRows,
270
+ indexViolations,
271
+ indexOrderWarnings,
272
+ checkAdrNumbering,
273
+ };
@@ -1319,6 +1319,12 @@ const CHECKS = [
1319
1319
  // doc-comment gate that blocks a ship buys filler prose, not understanding.
1320
1320
  // Making it blocking is an owner decision and needs its own ADR.
1321
1321
  require('./doc-comment-coverage.js').checkDocCommentCoverage,
1322
+ // Check 31 — task 1003164: two ADRs may not share a number, and docs/adr/README.md
1323
+ // must agree with the tree. The ADR half of the guard Check 11b already gives
1324
+ // migrations: same shared, monotonic, human-allocated number line, and until this
1325
+ // landed only one of the two was enforced. An ADR number is a citation target, so a
1326
+ // duplicate is wrong forever (ADR 0195).
1327
+ require('./adr-namespace.js').checkAdrNumbering,
1322
1328
  ];
1323
1329
 
1324
1330
  function runAll() {
@@ -1364,6 +1370,7 @@ module.exports = {
1364
1370
  checkKernelImportsNoDomain,
1365
1371
  checkModuleMigrationsAdditive,
1366
1372
  checkMigrationNamespacing, // Check 11b — ADR 0108 §5 cross-repo migration filenames
1373
+ checkAdrNumbering: require('./adr-namespace.js').checkAdrNumbering, // Check 31 — task 1003164 / ADR 0195: ADR numbers unique + index agrees with the tree
1367
1374
  checkDesignContract,
1368
1375
  checkKernelPromptsNoHardcodedIdentity, // BV1.R76 / task 1705
1369
1376
  scanPromptIdentity, // BV1.R76 — negative-test hook
@@ -14,6 +14,10 @@ const os = require('node:os');
14
14
  const { cliClient, rewardPolicy } = require('./cli-lib');
15
15
  const { gitOk } = require('./ship-git.js');
16
16
  const { resolveInstanceBase } = require('./ship-deploy-target.js');
17
+ // task 1003313: the shared onboarding/doctor preflight — reused (not duplicated)
18
+ // so the grader outage explains itself with the same `claude CLI` verdict doctor
19
+ // gives, and the two can never drift.
20
+ const { preflightChecks } = require('./preflight.js');
17
21
  const { computeRawDiffHash, reconstructPanelIssues, runSubagentGrader, shouldReuseGrade } = require('./ship-grade.js');
18
22
 
19
23
  // ---------- Shared grade-step helpers (used by shipMain AND regradeMain) ----------
@@ -223,7 +227,33 @@ async function gradeAndRecord({ taskId, task, summary, baseline, committedFiles,
223
227
  // issues + next-step hints). Pure + exported so the #943 grader-unavailable vs
224
228
  // FAIL branch is unit-tested without capturing stdout. printGradeOutcome just
225
229
  // prints what this returns.
226
- function formatGradeOutcome(g, taskId) {
230
+ // task 1003313: the per-worker diagnostic lines for a total outage, plus the
231
+ // local `claude` CLI verdict. Pure over its inputs (cliFinding is INJECTED, never
232
+ // spawned here) so formatGradeOutcome stays unit-testable without a subprocess.
233
+ function formatOutageDiagnostic(g, cliFinding) {
234
+ const out = [];
235
+ const dead = (g.signals?.workers || []).filter((w) => w && w.error);
236
+ const detailed = dead.filter((w) => w.exit_code != null || w.stderr_tail);
237
+ if (detailed.length) {
238
+ out.push('');
239
+ out.push('What the grader subprocess actually did:');
240
+ for (const w of detailed) {
241
+ const code = w.exit_code != null ? `exit ${w.exit_code}` : w.error;
242
+ out.push(` ${String(w.kind || '?').padEnd(11)} ${code}${w.stderr_tail ? ` — ${w.stderr_tail.split('\n')[0]}` : ''}`);
243
+ }
244
+ }
245
+ if (cliFinding && cliFinding.level !== 'ok') {
246
+ out.push('');
247
+ out.push(`Local \`claude\` CLI: ${cliFinding.detail}`);
248
+ if (cliFinding.fix) out.push(` Fix: ${cliFinding.fix}`);
249
+ }
250
+ return out;
251
+ }
252
+
253
+ // cliFinding: the preflight `claude CLI` row, injected by printGradeOutcome so a
254
+ // dark panel can say "your CLI is logged out" instead of leaving the builder to
255
+ // guess. Report-only — it never changes the outcome (ADR 0162: no new gates).
256
+ function formatGradeOutcome(g, taskId, cliFinding = null) {
227
257
  const out = [];
228
258
  // #943: a TOTAL grader outage (every gating worker failed to spawn / the
229
259
  // grader was bypassed pre-spawn) is NOT a quality rejection — it's "the grader
@@ -236,6 +266,7 @@ function formatGradeOutcome(g, taskId) {
236
266
  out.push('');
237
267
  out.push(`Subagent grade: GRADER UNAVAILABLE${reason ? ` (${reason})` : ''} · no quality signal produced — NOT a quality rejection`);
238
268
  if (g.notes_md) out.push(g.notes_md.split('\n').map((l) => ' ' + l).join('\n'));
269
+ out.push(...formatOutageDiagnostic(g, cliFinding));
239
270
  out.push('');
240
271
  out.push('The grader could not run (infra/outage/bypass) — there are no findings to fix. Task stays at completed.');
241
272
  out.push('When the grader is reachable again, re-grade:');
@@ -308,7 +339,17 @@ function formatGradeOutcome(g, taskId) {
308
339
  // last resort — see feedback_prefer_regrade_over_override). #943: a total grader
309
340
  // outage prints as "GRADER UNAVAILABLE", not a 0/10 FAIL — see formatGradeOutcome.
310
341
  function printGradeOutcome(g, taskId) {
311
- console.log(formatGradeOutcome(g, taskId));
342
+ // task 1003313: on a TOTAL outage only, ask the existing preflight whether the
343
+ // local `claude` CLI is launchable and logged in — the two faults that produce
344
+ // this exact all-workers-dead signature. Report-only and outage-only, so the
345
+ // happy path pays nothing and no ship is newly gated (ADR 0162).
346
+ let cliFinding = null;
347
+ if (g?.signals?.panel_outcome === 'unavailable') {
348
+ try {
349
+ cliFinding = preflightChecks().find((r) => r.title === 'claude CLI') || null;
350
+ } catch (_) { /* best-effort: a broken probe must never mask the grade output */ }
351
+ }
352
+ console.log(formatGradeOutcome(g, taskId, cliFinding));
312
353
  }
313
354
 
314
355
  // Print the credits line + multiplier breakdown + idea bonus + unlocked
package/src/module-api.js CHANGED
@@ -1206,8 +1206,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1206
1206
  // landed since 1.19.381 with no explicit bump. run 33094545251. (task 1002620)
1207
1207
  // 1.19.383 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1208
1208
  // landed since 1.19.382 with no explicit bump. run 33101389971. (task 1002620)
1209
+ // 1.19.384 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1210
+ // landed since 1.19.383 with no explicit bump. run 33116184189. (task 1002620)
1211
+ // 1.19.385 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1212
+ // landed since 1.19.384 with no explicit bump. run 33118682829. (task 1002620)
1209
1213
  // ---------------------------------------------------------------------------
1210
- const CORE_VERSION = '1.19.383'; // CI auto-patch carrier (ADR 0161); see changelog above
1214
+ const CORE_VERSION = '1.19.385'; // CI auto-patch carrier (ADR 0161); see changelog above
1211
1215
 
1212
1216
  // A namespaced logger so a module's log lines are attributable + consistent.
1213
1217
  // Usage: const log = api.logger('dev-box'); log.info('mounted');