@bongos/core 1.19.384 → 1.19.386

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 CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.384",
6
- "core_contract": "1.19.384",
7
- "source_commit": "c17ffedef449a2cb392b9bedc87fa31f1746625a",
5
+ "core_version": "1.19.386",
6
+ "core_contract": "1.19.386",
7
+ "source_commit": "c4899bbdaafe758d4521d6cb7944ad0117886573",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-27T21:02:17.519Z",
9
+ "built_at": "2026-08-27T21:58:01.005Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 338,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 1963,
20
- "tree_sha256": "d6620f625c1c0969033c8326d7e5d9490f054072e6a6d0eecef66552c2025290",
20
+ "tree_sha256": "856370a346d104a96b56e2fe9a3fefd50a50298a18e82c9afa13ef04387f6b7a",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -3537,7 +3537,7 @@
3537
3537
  {
3538
3538
  "path": "modules/grading/grader-workers/index.js",
3539
3539
  "mode": "0000644",
3540
- "sha256": "8ec5e91341ec5247ee643c6ce75fb5ec21a62b3bba176fdde060cdb352edb808"
3540
+ "sha256": "186ebc1d6456f3d9d0435766744fd767005b667fc4f6020bb9041f6b66388c4b"
3541
3541
  },
3542
3542
  {
3543
3543
  "path": "modules/grading/grader-workers/narc.js",
@@ -3567,7 +3567,7 @@
3567
3567
  {
3568
3568
  "path": "modules/grading/grader.js",
3569
3569
  "mode": "0000644",
3570
- "sha256": "bb66bece4bf5d1364be931b39d7b1e179615b59613d74e491ff48b399291ff24"
3570
+ "sha256": "53001adcb058742f39bdb475fca023809c239f50bf9dddc61ccef966d5d01d6a"
3571
3571
  },
3572
3572
  {
3573
3573
  "path": "modules/grading/module.json",
@@ -5117,12 +5117,12 @@
5117
5117
  {
5118
5118
  "path": "package-lock.json",
5119
5119
  "mode": "0000644",
5120
- "sha256": "d1bb9eb4376b39eb26a2fb46d91a70556c332521be0f193633d11807248f48ca"
5120
+ "sha256": "34407c6061bf61f8ec416b91f30da5ae1140c1e584e6721af64fbadadb60c9b0"
5121
5121
  },
5122
5122
  {
5123
5123
  "path": "package.json",
5124
5124
  "mode": "0000644",
5125
- "sha256": "cbb22bb9c93706c85329e51d1a67becc24d477f6c63baadadb2c6b10aa1cc59a"
5125
+ "sha256": "e49313249eef5acc6d3a2926aeb5725f0773d8d4300393a8ed57a3016a25a995"
5126
5126
  },
5127
5127
  {
5128
5128
  "path": "public-docs/index.html",
@@ -5782,7 +5782,7 @@
5782
5782
  {
5783
5783
  "path": "scripts/gds/docs-entropy.js",
5784
5784
  "mode": "0000644",
5785
- "sha256": "1a7cbe47158e4de63c1b788d6bb4f682584723a45c1824ab2ae3fbf92a54fbce"
5785
+ "sha256": "e052ddf4d44c0d67512cb14e150cd309a9ab09414c557db8319a57fd8f0d6961"
5786
5786
  },
5787
5787
  {
5788
5788
  "path": "scripts/gds/doctor.js",
@@ -6472,7 +6472,7 @@
6472
6472
  {
6473
6473
  "path": "scripts/gds/ship-grade-steps.js",
6474
6474
  "mode": "0000644",
6475
- "sha256": "52dc5ae1aca08033d2d217694273d8fed1597c0329491118ffa48ccdadc0a3ca"
6475
+ "sha256": "e68f4c2848cb59878cd0b96b48fdd075db4a035354d51afef52aa6225f7895e5"
6476
6476
  },
6477
6477
  {
6478
6478
  "path": "scripts/gds/ship-grade.js",
@@ -7032,7 +7032,7 @@
7032
7032
  {
7033
7033
  "path": "src/module-api.js",
7034
7034
  "mode": "0000644",
7035
- "sha256": "62c5f68383474bfeb692ea38e926c689615320d644d8d83fe7c35b3dd51e23bb"
7035
+ "sha256": "28a5f25668bc0297f421e200dbb658a5365db71874af4f8fd29c37016b4dcd29"
7036
7036
  },
7037
7037
  {
7038
7038
  "path": "src/module-loader/catalog.js",
@@ -7862,7 +7862,7 @@
7862
7862
  {
7863
7863
  "path": "tests/docs_entropy.mjs",
7864
7864
  "mode": "0000644",
7865
- "sha256": "4d64164d45d6b81d706074b8394da4f3d0f7f8c5263a37a287a163d6a746a96b"
7865
+ "sha256": "c3ef388bb7baacd4da4319fed82ceb5687e0d444cacce2ee84f9061dccbd1018"
7866
7866
  },
7867
7867
  {
7868
7868
  "path": "tests/docs_openapi_route.mjs",
@@ -8302,7 +8302,7 @@
8302
8302
  {
8303
8303
  "path": "tests/grade_unavailable_display.mjs",
8304
8304
  "mode": "0000644",
8305
- "sha256": "82746689da273a8bd9d53d80204b15147d87574d2331c3a70d736d95a6126a09"
8305
+ "sha256": "08e08714d025fe7c0f1177fbfa6724241ff519cd34848f953d5bc7cce6c142fa"
8306
8306
  },
8307
8307
  {
8308
8308
  "path": "tests/grader.mjs",
@@ -8322,7 +8322,7 @@
8322
8322
  {
8323
8323
  "path": "tests/grader_panel.mjs",
8324
8324
  "mode": "0000644",
8325
- "sha256": "2e62f5eb5896691df78e1c63f495d1cf7bea23dd1dfb8f2bd764e23e87982616"
8325
+ "sha256": "32dc0e219ed6aa7430c9ba9523faddb2ddc9602d939731788c660d9d79751d22"
8326
8326
  },
8327
8327
  {
8328
8328
  "path": "tests/grader_prompt_fencing.mjs",
@@ -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.384",
3
+ "version": "1.19.386",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.384",
9
+ "version": "1.19.386",
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.384",
3
+ "version": "1.19.386",
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",
@@ -89,7 +89,17 @@ function parseFile(absPath) {
89
89
  return { headings: [], links: [], paragraphs: [] };
90
90
  }
91
91
 
92
- const lines = raw.split('\n');
92
+ // Split on CRLF **or** LF, and drop a leading BOM (task 1003314). A Windows
93
+ // checkout gets CRLF from git, and splitting on a bare newline then leaves a
94
+ // trailing carriage return on every line — which the heading regex below
95
+ // cannot match, because `.` excludes line terminators and `$` is not in
96
+ // multiline mode. The file parsed as having ZERO headings, so every
97
+ // [text](file.md#anchor) pointing into it was reported as a broken anchor:
98
+ // doc_broken_anchors read 21 on Windows and 6 in CI, and the quality ratchet
99
+ // a Windows builder ran was pure noise. Same failure family as task 1002526's
100
+ // `git ls-files` quoting bug, one layer in — a gate that reports confidently
101
+ // and wrongly on one platform.
102
+ const lines = raw.replace(/^\uFEFF/, '').split(/\r?\n/);
93
103
  const headings = [];
94
104
  const links = [];
95
105
  const paragraphs = [];
@@ -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
@@ -1208,8 +1208,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1208
1208
  // landed since 1.19.382 with no explicit bump. run 33101389971. (task 1002620)
1209
1209
  // 1.19.384 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1210
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)
1213
+ // 1.19.386 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1214
+ // landed since 1.19.385 with no explicit bump. run 33120468176. (task 1002620)
1211
1215
  // ---------------------------------------------------------------------------
1212
- const CORE_VERSION = '1.19.384'; // CI auto-patch carrier (ADR 0161); see changelog above
1216
+ const CORE_VERSION = '1.19.386'; // CI auto-patch carrier (ADR 0161); see changelog above
1213
1217
 
1214
1218
  // A namespaced logger so a module's log lines are attributable + consistent.
1215
1219
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -12,12 +12,14 @@ import { test } from 'node:test';
12
12
  import { execFileSync } from 'node:child_process';
13
13
  import { createRequire } from 'node:module';
14
14
  import { fileURLToPath } from 'node:url';
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
15
17
  import path from 'node:path';
16
18
 
17
19
  const require = createRequire(import.meta.url);
18
20
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
21
  const SCANNER = path.join(ROOT, 'scripts', 'gds', 'docs-entropy.js');
20
- const { collectMarkdownFiles } = require(SCANNER); // require.main-guarded → no scan on import
22
+ const { collectMarkdownFiles, parseFile } = require(SCANNER); // require.main-guarded → no scan on import
21
23
 
22
24
  test('collectMarkdownFiles passes the glob as an argv array, never a shell string', () => {
23
25
  let seen = null;
@@ -53,3 +55,86 @@ test('the real scan enumerates the repo (the regression: it reported on 0 files)
53
55
  assert.ok(Number.isFinite(result.stale_links));
54
56
  assert.ok(Number.isFinite(result.broken_anchors));
55
57
  });
58
+
59
+ // --- CRLF: a Windows checkout must parse exactly like a Linux one (task 1003314) ---
60
+ //
61
+ // The bug: parseFile() split on a bare '\n', so on a Windows checkout (git hands
62
+ // out CRLF) every line kept a trailing '\r'. The heading regex /^(#{1,6})\s+(.+)$/
63
+ // cannot match that — '.' excludes line terminators and '$' is not in multiline
64
+ // mode — so EVERY CRLF file parsed as having zero headings, and every
65
+ // [text](file.md#anchor) pointing into one was reported broken. doc_broken_anchors
66
+ // read 21 on Windows against a baseline of 6, which made the quality ratchet
67
+ // unusable there: a builder could not tell a real regression from platform noise.
68
+ // Same family as the `git ls-files` quoting bug above — a gate that reports
69
+ // confidently and WRONGLY on one platform only.
70
+
71
+ const DOC_LINES = [
72
+ '# Title one',
73
+ '',
74
+ 'Some prose with a [link](other.md#deep-anchor) in it.',
75
+ '',
76
+ '## 12. Session Update Protocol',
77
+ '',
78
+ 'More prose.',
79
+ ];
80
+
81
+ // Write `text` to a temp .md and parse it — parseFile takes a path, not a string.
82
+ function parseString(text) {
83
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-entropy-eol-'));
84
+ try {
85
+ const file = path.join(dir, 'sample.md');
86
+ fs.writeFileSync(file, text);
87
+ return parseFile(file);
88
+ } finally {
89
+ fs.rmSync(dir, { recursive: true, force: true });
90
+ }
91
+ }
92
+
93
+ test('parseFile reads CRLF and LF identically — headings, slugs and links', () => {
94
+ const lf = parseString(DOC_LINES.join('\n'));
95
+ const crlf = parseString(DOC_LINES.join('\r\n'));
96
+
97
+ assert.equal(lf.headings.length, 2, 'fixture sanity: the LF form has two headings');
98
+ assert.deepEqual(
99
+ crlf.headings, lf.headings,
100
+ 'a CRLF file must parse to the same headings as the LF one — before this fix it parsed to NONE',
101
+ );
102
+ assert.deepEqual(crlf.links.map((l) => l.href), lf.links.map((l) => l.href));
103
+ });
104
+
105
+ test('parseFile slugs a CRLF heading without a trailing carriage return', () => {
106
+ const slugs = parseString(DOC_LINES.join('\r\n')).headings.map((h) => h.slug);
107
+ assert.deepEqual(
108
+ slugs, ['title-one', '12-session-update-protocol'],
109
+ 'the real anchor CLAUDE.md cites — a stray \\r in the slug is what made it report as "missing"',
110
+ );
111
+ assert.ok(!slugs.some((s) => s.includes('\r')), 'no slug carries a carriage return');
112
+ });
113
+
114
+ test('parseFile ignores a leading BOM, so a BOM file is not one heading short', () => {
115
+ // Same class, different byte: PowerShell writes UTF-8 WITH BOM by default
116
+ // (scripts/gds/CLAUDE.md warns about this for JSON bodies) and
117
+ // readFileSync('utf8') does not strip it — the BOM would sit before the first
118
+ // '#' and hide that heading.
119
+ const withBom = parseString('' + DOC_LINES.join('\r\n'));
120
+ assert.equal(withBom.headings.length, 2, 'the first heading survives a BOM');
121
+ assert.equal(withBom.headings[0].text, 'Title one');
122
+ });
123
+
124
+ test('the real scan on THIS checkout matches the CI baselines (the platform-parity proof)', () => {
125
+ // The point of the fix: the number a Windows builder sees is the number CI
126
+ // sees. Read the baseline rather than hardcoding it, so this stays honest as
127
+ // the ratchet legitimately tightens.
128
+ const stdout = execFileSync(process.execPath, [SCANNER, '--json'], {
129
+ cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
130
+ });
131
+ const result = JSON.parse(stdout);
132
+ const baselines = JSON.parse(
133
+ fs.readFileSync(path.join(ROOT, 'config', 'fitness-baselines.json'), 'utf8'),
134
+ ).metrics;
135
+ assert.ok(
136
+ result.broken_anchors <= baselines.doc_broken_anchors,
137
+ `broken_anchors ${result.broken_anchors} exceeds the baseline ${baselines.doc_broken_anchors} — `
138
+ + 'on a CRLF checkout this read 21, because every heading in every CRLF file went unparsed',
139
+ );
140
+ });
@@ -35,6 +35,54 @@ test('grader-unavailable grade → "GRADER UNAVAILABLE", NOT a 0/10 FAIL or "fix
35
35
  assert.doesNotMatch(out, /blended .*\/10/, 'must NOT print a 0/10 quality score for a non-grade');
36
36
  });
37
37
 
38
+ // ---------- task 1003313: the outage card says WHY ----------
39
+ //
40
+ // Before this, a total outage printed "GRADER UNAVAILABLE" and stopped — the
41
+ // builder had no way to tell a logged-out CLI from a Windows alias stub from a
42
+ // real API outage, and two builders stayed dark for days. The diagnostic and the
43
+ // local `claude` CLI verdict are report-only: they never change the outcome.
44
+
45
+ const OUTAGE = {
46
+ passed: false, score: 0, threshold: 6, notes_md: 'GRADER UNAVAILABLE — all 4/4 gating worker(s) failed to run',
47
+ rubric_scores: {}, issues: [],
48
+ signals: {
49
+ panel_outcome: 'unavailable',
50
+ workers: ['narc', 'quality', 'hacker', 'efficiency'].map((kind) => ({
51
+ kind, error: 'SUBAGENT_NONZERO_EXIT', exit_code: 1, stderr_tail: 'Invalid API key · Run /login',
52
+ })),
53
+ },
54
+ };
55
+
56
+ test('an outage card prints each dead worker with its exit code + CLI error (task 1003313)', () => {
57
+ const out = formatGradeOutcome(OUTAGE, 1003313);
58
+ assert.match(out, /GRADER UNAVAILABLE/);
59
+ assert.match(out, /What the grader subprocess actually did/);
60
+ assert.match(out, /narc\s+exit 1 — Invalid API key/, 'names the worker, its exit code, and what the CLI said');
61
+ assert.doesNotMatch(out, /Fix the issues above/, 'still not a quality rejection');
62
+ });
63
+
64
+ test('an outage card relays the injected claude-CLI preflight finding + its fix (task 1003313)', () => {
65
+ const out = formatGradeOutcome(OUTAGE, 1003313, {
66
+ level: 'warn', title: 'claude CLI',
67
+ detail: 'launches (2.1.0) but is not logged in',
68
+ fix: 'Run `claude login`',
69
+ });
70
+ assert.match(out, /Local `claude` CLI: launches \(2\.1\.0\) but is not logged in/);
71
+ assert.match(out, /Fix: Run `claude login`/);
72
+ });
73
+
74
+ test('a HEALTHY claude-CLI finding adds no noise to the outage card (task 1003313)', () => {
75
+ const out = formatGradeOutcome(OUTAGE, 1003313, { level: 'ok', title: 'claude CLI', detail: 'logged in' });
76
+ assert.doesNotMatch(out, /Local `claude` CLI/, 'an ok finding is not worth a line');
77
+ });
78
+
79
+ test('an outage from a pre-propagation server prints no empty diagnostic block (task 1003313)', () => {
80
+ const g = { ...OUTAGE, signals: { panel_outcome: 'unavailable', workers: [{ kind: 'narc', error: 'SUBAGENT_NONZERO_EXIT' }] } };
81
+ const out = formatGradeOutcome(g, 1003313);
82
+ assert.match(out, /GRADER UNAVAILABLE/);
83
+ assert.doesNotMatch(out, /What the grader subprocess actually did/);
84
+ });
85
+
38
86
  test('a real FAIL still prints the FAIL block + fix-the-issues guidance (#943 regression guard)', () => {
39
87
  const g = {
40
88
  passed: false, score: 4, threshold: 6,
@@ -246,6 +246,90 @@ await atest('graderUnavailableResult: server-compat shape (null axes, fallthroug
246
246
  assert.equal(r.passed, false);
247
247
  });
248
248
 
249
+ // ---------- task 1003313: a dark panel names its own cause ----------
250
+ //
251
+ // The regression these pin: runWorker used to keep only err.code and throw away
252
+ // err.exit_code / err.stderr, so a total outage stored four identical
253
+ // SUBAGENT_NONZERO_EXIT rows and two builders sat dark for days with a 0/10 card
254
+ // that could not say whether the CLI was logged out, aliased, or the API down.
255
+
256
+ const workersMod = require('../modules/grading/grader-workers/index.js');
257
+ const { runWorker, redactStderrTail, STDERR_TAIL_MAX } = workersMod;
258
+
259
+ // A worker module stub: buildPrompt/extractJson/computeVerdict, no spawning.
260
+ const STUB_WORKER = {
261
+ KIND: 'quality', NAME: 'Quality', SPECIALTY: 'stub',
262
+ buildPrompt: () => 'prompt',
263
+ extractJson: (s) => JSON.parse(s),
264
+ computeVerdict: (parsed, meta) => (meta.subprocess_error
265
+ ? { verdict: 'fail', scores: {}, issues: [], notes_md: '', confidence: 0, error: meta.subprocess_error }
266
+ : { verdict: 'pass', scores: { code_quality: 9 }, issues: [], notes_md: '', confidence: 0.9, error: null }),
267
+ };
268
+
269
+ // The exact error runSubagent throws on a nonzero exit.
270
+ const nonzeroExit = ({ exitCode = 1, stderr = '', stdout = '' } = {}) =>
271
+ Object.assign(new Error(`subagent exited with code ${exitCode}`), {
272
+ code: 'SUBAGENT_NONZERO_EXIT', exit_code: exitCode, run_ms: 120, stdout, stderr,
273
+ });
274
+
275
+ await atest('runWorker: a dead subprocess carries exit_code + stderr_tail onto the worker row (task 1003313)', async () => {
276
+ const r = await runWorker(STUB_WORKER, {
277
+ task: {}, diff: '', changedFiles: [], diffStats: {}, model: 'm', runOpts: {},
278
+ runSubagentFn: async () => { throw nonzeroExit({ exitCode: 1, stderr: 'Invalid API key · Run /login' }); },
279
+ });
280
+ assert.equal(r.error, 'SUBAGENT_NONZERO_EXIT');
281
+ assert.equal(r.exit_code, 1, 'the exit code must survive the catch');
282
+ assert.match(r.stderr_tail, /Invalid API key/, "the CLI's own error line must survive the catch");
283
+ assert.equal(r.retried, true, 'a transient error is still retried once (#465)');
284
+ });
285
+
286
+ await atest('runWorker: a clean run adds neither exit_code nor stderr_tail (task 1003313)', async () => {
287
+ const r = await runWorker(STUB_WORKER, {
288
+ task: {}, diff: '', changedFiles: [], diffStats: {}, model: 'm', runOpts: {},
289
+ runSubagentFn: async () => ({ stdout: '{"code_quality":9}', run_ms: 10, cost_usd: 0.5 }),
290
+ });
291
+ assert.equal(r.verdict, 'pass');
292
+ assert.equal('exit_code' in r, false, 'a healthy grade row keeps its existing shape');
293
+ assert.equal('stderr_tail' in r, false);
294
+ });
295
+
296
+ await atest('runWorker: stdout is the fallback when the CLI explained itself there (task 1003313)', async () => {
297
+ const r = await runWorker(STUB_WORKER, {
298
+ task: {}, diff: '', changedFiles: [], diffStats: {}, model: 'm', runOpts: {},
299
+ runSubagentFn: async () => { throw nonzeroExit({ exitCode: 1, stderr: '', stdout: 'Not logged in.' }); },
300
+ });
301
+ assert.match(r.stderr_tail, /Not logged in/);
302
+ });
303
+
304
+ test('redactStderrTail: strips secrets, strips ANSI, bounds the length (task 1003313)', () => {
305
+ assert.match(redactStderrTail('auth failed for sk-ant-api03-AbCdEf_12345'), /\[redacted\]/);
306
+ assert.doesNotMatch(redactStderrTail('token ghp_abcdefghijklmnopqrstuvwxyz0123'), /ghp_/);
307
+ assert.doesNotMatch(redactStderrTail('header Bearer eyJhbGciOiJIUzI1NiJ9.abc'), /eyJhbGci/);
308
+ assert.equal(redactStderrTail('\u001B[31mred error\u001B[0m'), 'red error', 'ANSI colour codes are stripped');
309
+ assert.equal(redactStderrTail('x'.repeat(STDERR_TAIL_MAX + 200)).length, STDERR_TAIL_MAX);
310
+ assert.equal(redactStderrTail(' '), null, 'an empty tail is null, not an empty string');
311
+ assert.equal(redactStderrTail(undefined), null);
312
+ });
313
+
314
+ test('aggregatePanel: the GRADER UNAVAILABLE verdict names exit code + CLI error (task 1003313)', () => {
315
+ const dead = (kind) => ({
316
+ kind, verdict: 'fail', error: 'SUBAGENT_NONZERO_EXIT', scores: {}, issues: [], confidence: 0,
317
+ exit_code: 1, stderr_tail: 'Invalid API key · Run /login',
318
+ });
319
+ const r = aggregatePanel([dead('narc'), dead('quality'), dead('hacker'), dead('efficiency')], {});
320
+ assert.equal(r.signals.panel_outcome, 'unavailable');
321
+ assert.match(r.notes_md, /GRADER UNAVAILABLE/);
322
+ assert.match(r.notes_md, /exit 1/, 'the verdict text must carry the exit code');
323
+ assert.match(r.notes_md, /Invalid API key/, 'the verdict text must carry the CLI error line');
324
+ });
325
+
326
+ test('aggregatePanel: no diagnostic appended when the workers carry none (pre-propagation grades)', () => {
327
+ const dead = (kind) => ({ kind, verdict: 'fail', error: 'SUBAGENT_NONZERO_EXIT', scores: {}, issues: [], confidence: 0 });
328
+ const r = aggregatePanel([dead('narc'), dead('quality'), dead('hacker'), dead('efficiency')], {});
329
+ assert.equal(r.signals.panel_outcome, 'unavailable');
330
+ assert.doesNotMatch(r.notes_md, /Diagnostic —/);
331
+ });
332
+
249
333
  console.log(`${asyncPassed} passed, ${asyncFailed} failed (async)`);
250
334
  const totalFailed = failed + asyncFailed;
251
335
  console.log(`\n${passed + asyncPassed} passed, ${totalFailed} failed`);