@bongos/core 1.19.757 → 1.19.759

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.757",
6
- "core_contract": "1.19.757",
7
- "source_commit": "cc213a1bab1fc07008953502c035d32a98ced469",
5
+ "core_version": "1.19.759",
6
+ "core_contract": "1.19.759",
7
+ "source_commit": "707e9f0b5bf49699402632ba77c655817675f27c",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-16T02:31:57.888Z",
9
+ "built_at": "2026-09-16T03:03:00.033Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 483,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2623,
20
- "tree_sha256": "6dfa78abe36908cd3c7762f36e1145d5ca9a22362b4825d6eb8c240e05a01815",
20
+ "tree_sha256": "72ddc6e8bfc5b6eb4aa5ab50108bbe35d4639bb91cad6318c0d6359b3c86ceec",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2512,7 +2512,7 @@
2512
2512
  {
2513
2513
  "path": "docs/module-api-changelog.md",
2514
2514
  "mode": "0000644",
2515
- "sha256": "c415a541beec31293c9fe17473fce5706b0c043818a3da9d2c092d6a36892a12"
2515
+ "sha256": "1c38ce9a7932e516d69df1e3deaf499f2a8e10ef545e8d4621919eef11a14b8a"
2516
2516
  },
2517
2517
  {
2518
2518
  "path": "docs/modules-contract.md",
@@ -5757,7 +5757,7 @@
5757
5757
  {
5758
5758
  "path": "modules/lifecycle/ship-card.js",
5759
5759
  "mode": "0000644",
5760
- "sha256": "c0e5a079c586e689d569d764526be5d99808262c9773ef17902e2416047494ab"
5760
+ "sha256": "0822f317a777c624880b86a02f9c63680fead1fdab79b13ba308801bed04a036"
5761
5761
  },
5762
5762
  {
5763
5763
  "path": "modules/lifecycle/ship-preflight.js",
@@ -7567,12 +7567,12 @@
7567
7567
  {
7568
7568
  "path": "package-lock.json",
7569
7569
  "mode": "0000644",
7570
- "sha256": "93d77133102c1137fe1e2772f30fabcd7e4630aece1c1ec238db81580f23f634"
7570
+ "sha256": "d3aebc78f2bcf902f69e9f27bb7375410de5851af241dbaebf7aefe729465f69"
7571
7571
  },
7572
7572
  {
7573
7573
  "path": "package.json",
7574
7574
  "mode": "0000644",
7575
- "sha256": "eb18ccfda9df0b09d536acaff0fdb18df77860d53a35cfa65b41cc1aa70e361c"
7575
+ "sha256": "83cc5d9eb22a0201d8a6bca2a74392b23eb9713aa78a651200552a3e2710b273"
7576
7576
  },
7577
7577
  {
7578
7578
  "path": "public-docs/index.html",
@@ -9382,7 +9382,7 @@
9382
9382
  {
9383
9383
  "path": "src/module-api.js",
9384
9384
  "mode": "0000644",
9385
- "sha256": "9798743d1dae3c952b257d48b1ee56341bf0a946504ebb6be66fd88f1a8cddcd"
9385
+ "sha256": "8ec75227e0ef1df2b932e943d085151cbb47133212aae42c86b425fb664a3c4c"
9386
9386
  },
9387
9387
  {
9388
9388
  "path": "src/module-loader/catalog.js",
@@ -10882,7 +10882,7 @@
10882
10882
  {
10883
10883
  "path": "tests/government_parity.mjs",
10884
10884
  "mode": "0000644",
10885
- "sha256": "0c619508691d41f41b0fbbb1d6939ba9ac7fde2ef4ec08ae7622bfa70bfbf624"
10885
+ "sha256": "0d2df586d3ee58990b611799334e21f59ad69292285a787e776c16ef11c64625"
10886
10886
  },
10887
10887
  {
10888
10888
  "path": "tests/government_principals.mjs",
@@ -12587,7 +12587,7 @@
12587
12587
  {
12588
12588
  "path": "tests/ship_card.mjs",
12589
12589
  "mode": "0000644",
12590
- "sha256": "aa56710e7655349b64c5decad5d47ed5df1e1cbaeed40d2c33084a9dbe634899"
12590
+ "sha256": "67acdeb4d84a312af063c8a152724a5e81dfe7beb7d1d0b9bee2d942cc0bebd0"
12591
12591
  },
12592
12592
  {
12593
12593
  "path": "tests/ship_card_injection.mjs",
@@ -1988,5 +1988,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1988
1988
  landed since 1.19.755 with no explicit bump. run 35047623582. (task 1002620)
1989
1989
  1.19.757 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1990
1990
  landed since 1.19.756 with no explicit bump. run 35048307666. (task 1002620)
1991
+ 1.19.758 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1992
+ landed since 1.19.757 with no explicit bump. run 35049006030. (task 1002620)
1993
+ 1.19.759 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1994
+ landed since 1.19.758 with no explicit bump. run 35050268139. (task 1002620)
1991
1995
  ---------------------------------------------------------------------------
1992
1996
  ```
@@ -88,6 +88,39 @@ function stageFor(postGradeStatus) {
88
88
  return 'failed';
89
89
  }
90
90
 
91
+ // Did the grade fail to HAPPEN, rather than fail? (task 1002892)
92
+ //
93
+ // stageFor() collapses every non-shipped, non-confirmed outcome into 'failed',
94
+ // whose one presentation is red / "Needs fixes" / "grade failed". An OUTAGE lands
95
+ // there too, and then the card tells a builder their code was rejected when in
96
+ // fact nothing judged it: on task 1002459 a logged-out CLI killed all four panel
97
+ // workers and the builder got a 0/10 "grade failed" card with an EMPTY issues[],
98
+ // then went looking for code problems that did not exist. ship.js has always said
99
+ // this correctly in its CLI text ("GRADER UNAVAILABLE ... NOT a quality
100
+ // rejection") and the server excludes unavailable panels from its aggregates —
101
+ // only the card lagged.
102
+ //
103
+ // Returns the outage kind or null. Pure, and reads ONLY the signals the server
104
+ // already sets, so the card never re-derives a verdict of its own:
105
+ // 'panel' — the whole panel never produced a signal (#943). Unambiguous.
106
+ // 'cq_lens' — the gating code_quality lens alone went dark (task 1003318). This
107
+ // one carries a guard: it is only an outage while no lens raised a
108
+ // blocker/major. A dark lens beside a real finding is a rejection
109
+ // with a missing axis, and must keep the ordinary red card.
110
+ //
111
+ // The twin of this predicate is formatGradeOutcome in scripts/gds/ship-grade-steps.js,
112
+ // which decides the same two shapes for the CLI text. Change one, look at the other.
113
+ function gradeUnavailability(grade) {
114
+ const g = grade || {};
115
+ const s = g.signals || {};
116
+ if (s.panel_outcome === 'unavailable') return 'panel';
117
+ const issues = Array.isArray(g.issues) ? g.issues : [];
118
+ const substantive = issues.some((i) => i && (i.severity === 'blocker' || i.severity === 'major'));
119
+ const scores = g.rubric_scores || {};
120
+ if (s.cq_lens_unavailable === true && scores.code_quality === null && !substantive) return 'cq_lens';
121
+ return null;
122
+ }
123
+
91
124
  // The one canonical journey every card draws, in order. Each node conjugates to
92
125
  // three forms — done (past participle), active (present continuous), future (bare
93
126
  // verb) — so the same stage reads 'built' / 'building' / 'build' depending on
@@ -103,6 +136,9 @@ const FORMS = Object.freeze({
103
136
  ship: { done: 'shipped', active: 'shipping', future: 'ship' },
104
137
  });
105
138
  const GRADE_FAILED = 'grade failed';
139
+ // task 1002892: the confirm node's word when the grade did not RUN. Two words, so
140
+ // the " → " joint logic that already keeps 'grade failed' intact covers it too.
141
+ const NOT_GRADED = 'not graded';
106
142
 
107
143
  // The journey, mapped: one entry per PIPELINE node, conjugated + classified by
108
144
  // position relative to the current node (`cfg.at`). Both renderers consume this
@@ -118,7 +154,10 @@ function trailSteps(cfg) {
118
154
  const f = FORMS[key];
119
155
  if (i < at) return { word: f.done, state: 'done', checked: true, bold: false };
120
156
  if (i === at) {
121
- if (cfg.currentKind === 'failed') return { word: GRADE_FAILED, state: 'current', checked: false, bold: true };
157
+ // task 1002892: the same node reads 'not graded' when the grade never ran.
158
+ // cfg.trailWord is set only by the outage override in buildCardModel; every
159
+ // other caller leaves it undefined and gets GRADE_FAILED exactly as before.
160
+ if (cfg.currentKind === 'failed') return { word: cfg.trailWord || GRADE_FAILED, state: 'current', checked: false, bold: true };
122
161
  if (cfg.currentKind === 'done') return { word: f.done, state: 'current', checked: true, bold: true };
123
162
  return { word: f.active, state: 'current', checked: false, bold: true }; // 'active'
124
163
  }
@@ -279,9 +318,24 @@ function buildCardModel(input = {}) {
279
318
  const landBailed = !!(stage === 'confirmed' && land && land.ok === false && !land.pending && land.reason);
280
319
  // A bailed land is not "confirmed — landing". Recolor the banner to match the
281
320
  // truth; the underlying STAGES entry is left untouched (it's frozen + shared).
321
+ // task 1002892: an outage is not a rejection. Same shape as landBailed above —
322
+ // recolor a copy, never mutate the frozen shared STAGES entry — and equally
323
+ // absent-signal-safe: a grade carrying no outage signals yields null here, so
324
+ // every pre-existing caller renders exactly as it did before.
325
+ // `input.grade`, not the `grade` local below — that is declared after this line.
326
+ const outage = stage === 'failed' ? gradeUnavailability(input.grade) : null;
282
327
  const cfg = landBailed
283
328
  ? { ...baseCfg, tone: 'warning', icon: 'ti-alert-triangle', statusWord: 'Not landed', headline: (id) => `Task #${id} confirmed — NOT landed` }
284
- : baseCfg;
329
+ : outage
330
+ ? {
331
+ ...baseCfg,
332
+ tone: 'warning',
333
+ icon: 'ti-cloud-off',
334
+ statusWord: 'Could not grade',
335
+ headline: (id) => `Task #${id} — could not grade`,
336
+ trailWord: NOT_GRADED,
337
+ }
338
+ : baseCfg;
285
339
  const brand = input.brand || safeBrand();
286
340
  const taskId = input.taskId;
287
341
  const grade = input.grade || {};
@@ -319,7 +373,15 @@ function buildCardModel(input = {}) {
319
373
  // Grade: pass · panel N/N | skipped | fail · N issues
320
374
  let gradeText = null;
321
375
  let gradeCls = 'cc-good';
322
- if (stage === 'failed') {
376
+ if (outage) {
377
+ // task 1002892: recoloring the banner alone was not enough — the Grade row
378
+ // and its HTML chip still read a red 'fail' right beside the amber "Could
379
+ // not grade", which is the same rejection signal this change exists to
380
+ // remove, just one line lower. There is no score to report when nothing
381
+ // ran, so say that instead of reporting a verdict.
382
+ gradeText = 'not graded';
383
+ gradeCls = '';
384
+ } else if (stage === 'failed') {
323
385
  gradeText = issues.length ? `fail · ${issues.length} issue${issues.length === 1 ? '' : 's'}` : 'fail';
324
386
  gradeCls = 'cc-bad';
325
387
  } else if (grade.skipped) {
@@ -411,9 +473,17 @@ function buildCardModel(input = {}) {
411
473
 
412
474
  // task 1002541: a bail knows the precise recovery command (re-run the ship,
413
475
  // re-auth, pass --db-only…), which beats the stage's generic '/merge-mode'.
476
+ // task 1002892: 'Fix & re-grade' is wrong advice after an outage — there is
477
+ // nothing to fix, and telling a builder otherwise is what sent one hunting
478
+ // through a diff for findings that were never produced. Retry instead. For a
479
+ // total panel outage the CLI's diagnostic also points at /grader-health, but the
480
+ // card gets ONE action, and re-running the grade is the step that either works
481
+ // or reproduces the outage with its reason.
414
482
  const action = (landBailed && land.nextStep)
415
483
  ? { kind: 'prompt', label: 'Land it', cmd: land.nextStep }
416
- : nextActionFor(stage, { taskId, discipline: input.discipline, sandboxUrl: input.sandboxUrl });
484
+ : outage
485
+ ? { kind: 'prompt', label: 'Try grading again', cmd: `node scripts/gds/ship.js ${taskId} --regrade` }
486
+ : nextActionFor(stage, { taskId, discipline: input.discipline, sandboxUrl: input.sandboxUrl });
417
487
 
418
488
  return {
419
489
  stage, cfg, brand, taskId,
@@ -724,7 +794,7 @@ function emitCard(model, taskId, { log = console.log, widgetsEnabled = true } =
724
794
  }
725
795
 
726
796
  module.exports = {
727
- STAGES, TONE, stageFor, buildCardModel, renderMonospaceCard, renderCardHtml, emitCard,
797
+ STAGES, TONE, stageFor, gradeUnavailability, buildCardModel, renderMonospaceCard, renderCardHtml, emitCard,
728
798
  // task 1001730 — the guaranteed one-line confirmation (pure; every stage pinned).
729
799
  confirmLine,
730
800
  // task 1003155 — the one-line sanitizer + the whole-line cap, exported so the
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.757",
3
+ "version": "1.19.759",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.757",
9
+ "version": "1.19.759",
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.757",
3
+ "version": "1.19.759",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/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.757'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.759'; // 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');
@@ -237,6 +237,166 @@ test('B0: every guards note that names a concrete route names one that EXISTS',
237
237
  `guards notes point at routes that do not exist — fix the note (or the route), never ignore it:\n ${dangling.join('\n ')}`);
238
238
  });
239
239
 
240
+ // ═══ B0b — a guards note naming a PAGE GATE names a live one, for its own atom ══
241
+ //
242
+ // task 1002878, from idea 1000625 (and independently from the BV1.R107 ship grade's
243
+ // hacker worker). B0 above pins a note that names a ROUTE. A note that names a page
244
+ // GATE was checked by nothing at all, and the `guards` field is the route-to-atom map
245
+ // every migration is instructed to read as authoritative.
246
+ //
247
+ // WHAT WENT WRONG ONCE. page.view.builder's note read 'authenticated hall pages
248
+ // (requireNonXenosPage / requireBuilderPage)'. Those two gates never admitted the same
249
+ // people: requireBuilderPage enforces page.view.public (floor xenos), requireNonXenosPage
250
+ // enforced page.view.builder (floor thetes). Migrating requireBuilderPage onto
251
+ // page.view.builder on the strength of that note would have locked every Xenos out of
252
+ // Work, Settings, pair, atlas, goals, roadmap and task-detail. R107 caught it by hand and
253
+ // used the right atom, so no lockout ever shipped — but the note stayed wrong, and the
254
+ // next reader had no reason to distrust it.
255
+ //
256
+ // This check would have failed on that note the day it was written: requireBuilderPage
257
+ // enforces page.view.public, not page.view.builder, so the pair disagrees mechanically.
258
+ //
259
+ // IT ALSO CATCHES THE STALE HALF. Task 1003450 has since deleted requireNonXenosPage
260
+ // along with /not-ready. A note still naming a gate that no longer exists reads as
261
+ // authoritative and resolves to nothing; an unknown gate name is therefore a failure
262
+ // here, not a skip — failing open is how the first version of this problem survived.
263
+ //
264
+ // PARSED FROM SOURCE, NOT IMPORTED. PAGE_GATES is internal to auth.js's page-gate
265
+ // dispatcher and deliberately unexported. Exporting it so a test could read it would
266
+ // widen the surface of a PROTECTED file (src/bongos/auth.js, group authz) for a test's
267
+ // convenience — the same trade tests/government_protected_surfaces.mjs declines for
268
+ // HARD_FLOOR_GLOBS, and for the same reason.
269
+ // PURE, and split out from the live assertion deliberately. Today's catalog is CORRECT
270
+ // (task 1003450 fixed the one bad note), so a check that only asserts "no problems
271
+ // against the real tree" passes without ever executing the branches that find one — a
272
+ // capture-group swap could make it vacuous forever and nothing would say so. The module's
273
+ // own precedent for a load-bearing invariant is government_ownership_scope.mjs F5–F8,
274
+ // which loads a MUTATED copy to prove each assert actually fires; these two functions are
275
+ // what let this do the same, in the negative tests below.
276
+ function parsePageGates(src) {
277
+ const block = src.match(/const PAGE_GATES = Object\.freeze\(\{([\s\S]*?)\n\}\);/);
278
+ if (!block) return null; // caller decides whether an absent table is fatal
279
+ const out = new Map();
280
+ // Each entry's own object literal is bounded by [^}]* so the scan cannot run past a
281
+ // gate that lacks a `permission` key and silently adopt the NEXT gate's. `permission`
282
+ // is then found ANYWHERE inside that literal rather than assumed to come first —
283
+ // requireProjectCuratePage already carries a second key, and key order in an object
284
+ // literal is not a contract anyone maintains.
285
+ for (const m of block[1].matchAll(/(require[A-Za-z]*Page):\s*Object\.freeze\(\{([^}]*)\}\)/g)) {
286
+ const perm = m[2].match(/permission:\s*'([^']+)'/);
287
+ if (perm) out.set(m[1], perm[1]);
288
+ }
289
+ return out;
290
+ }
291
+
292
+ // Any require*Page identifier mentioned anywhere in a guards note, prose included —
293
+ // which is the whole point, since B0's route regex only ever looked at the leading
294
+ // `METHOD /path` and prose went entirely unchecked.
295
+ const PAGE_GATE_RE = /\b(require[A-Za-z]*Page)\b/g;
296
+
297
+ // The comparison itself. Returns every disagreement plus how many notes participated,
298
+ // so the caller can tell "nothing is wrong" from "nothing was looked at".
299
+ function guardGateProblems(gates, permissions) {
300
+ const problems = [];
301
+ let named = 0;
302
+ for (const p of permissions) {
303
+ for (const [, gate] of String(p.guards || '').matchAll(PAGE_GATE_RE)) {
304
+ named += 1;
305
+ if (!gates.has(gate)) {
306
+ problems.push(`${p.key}: names '${gate}', which is not a gate in PAGE_GATES — `
307
+ + 'either the gate was deleted and the note was left behind, or the name is a typo.');
308
+ continue;
309
+ }
310
+ if (gates.get(gate) !== p.key) {
311
+ problems.push(`${p.key}: names '${gate}', but that gate enforces '${gates.get(gate)}'. `
312
+ + 'A guards note is the route-to-atom map a migration reads as authoritative, so a '
313
+ + 'gate listed against the wrong atom is an instruction to gate the wrong audience.');
314
+ }
315
+ }
316
+ }
317
+ return { problems, named };
318
+ }
319
+
320
+ test('B0b: a guards note naming a page gate names a live one, for its own atom', () => {
321
+ const gates = parsePageGates(fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'auth.js'), 'utf8'));
322
+ assert.ok(gates, 'could not find the PAGE_GATES table in src/bongos/auth.js — if it '
323
+ + 'moved or changed shape, teach this test the new one rather than deleting it: it is '
324
+ + 'the only thing checking that a guards note names a gate that agrees with it.');
325
+ assert.ok(gates.size >= 4,
326
+ `fixture sanity: parsed ${gates.size} page gate(s) out of auth.js's PAGE_GATES, and the `
327
+ + 'table has held at least four since task 1003450 removed the two dead ones. A parse '
328
+ + 'returning few or none makes every assertion below vacuous.');
329
+
330
+ const { problems, named } = guardGateProblems(gates, FLOORED);
331
+ assert.ok(named >= 1,
332
+ 'fixture sanity: no guards note named a page gate at all. One does today '
333
+ + '(page.view.government → requireGovernmentPage), so either the note was removed — in '
334
+ + 'which case delete this floor deliberately — or PAGE_GATE_RE has stopped matching and '
335
+ + 'this test is now checking nothing.');
336
+ assert.deepEqual(problems, [],
337
+ `guards notes disagree with the page gates they name:\n ${problems.join('\n ')}`);
338
+ });
339
+
340
+ // ── B0b negative fixtures — proof the check above can actually FAIL ───────────────
341
+ // The real catalog is correct, so B0b passing proves only that it found no problem, not
342
+ // that it can find one. These replay the HISTORICAL defect verbatim against a synthetic
343
+ // pair, which is the repeatable form of the throwaway probe that was run by hand.
344
+
345
+ const GATES_FIXTURE = new Map([
346
+ ['requireBuilderPage', 'page.view.public'],
347
+ ['requireGovernmentPage', 'page.view.government'],
348
+ ]);
349
+
350
+ test('B0b catches the original defect: a gate listed against the WRONG atom', () => {
351
+ // page.view.builder's note as it actually stood — requireBuilderPage enforces
352
+ // page.view.public, so migrating on the strength of it would have locked every Xenos
353
+ // out of Work, Settings, pair, atlas, goals, roadmap and task-detail.
354
+ const { problems, named } = guardGateProblems(GATES_FIXTURE, [
355
+ { key: 'page.view.builder', guards: 'authenticated hall pages (requireBuilderPage)' },
356
+ ]);
357
+ assert.equal(named, 1);
358
+ assert.equal(problems.length, 1);
359
+ assert.match(problems[0], /names 'requireBuilderPage', but that gate enforces 'page\.view\.public'/);
360
+ });
361
+
362
+ test('B0b catches a note left pointing at a DELETED gate', () => {
363
+ // requireNonXenosPage went with /not-ready in task 1003450. A note outliving its gate
364
+ // reads as authoritative and resolves to nothing, so an unknown name must FAIL here
365
+ // rather than skip — failing open is how the original problem survived unnoticed.
366
+ const { problems } = guardGateProblems(GATES_FIXTURE, [
367
+ { key: 'page.view.builder', guards: 'authenticated hall pages (requireNonXenosPage)' },
368
+ ]);
369
+ assert.equal(problems.length, 1);
370
+ assert.match(problems[0], /names 'requireNonXenosPage', which is not a gate in PAGE_GATES/);
371
+ });
372
+
373
+ test('B0b stays silent on a note whose gate agrees with it, and on notes naming none', () => {
374
+ // The permissive half, asserted as deliberately as the strict ones: a correct note and
375
+ // a prose-only note must BOTH pass, or the check would just be noise someone disables.
376
+ const { problems, named } = guardGateProblems(GATES_FIXTURE, [
377
+ { key: 'page.view.government', guards: 'the government pages — /watch (requireGovernmentPage)' },
378
+ { key: 'page.view.builder', guards: 'nothing in the core today — seeded and attachable' },
379
+ ]);
380
+ assert.deepEqual(problems, []);
381
+ assert.equal(named, 1, 'only the note naming a gate participates');
382
+ });
383
+
384
+ test('parsePageGates reads permission wherever it sits, and never borrows a sibling gate\'s', () => {
385
+ // The nit the grade raised: the old inner regex assumed `permission` came first in a
386
+ // gate's literal. It does today; key order is not a contract. A gate with no permission
387
+ // key at all must be DROPPED, never silently given the next gate's.
388
+ const gates = parsePageGates([
389
+ 'const PAGE_GATES = Object.freeze({',
390
+ " requireSecondKeyPage: Object.freeze({ deniedTo: '/builders', permission: 'a.b' }),",
391
+ " requireNoPermissionPage: Object.freeze({ deniedTo: '/builders' }),",
392
+ " requireNormalPage: Object.freeze({ permission: 'c.d', deniedTo: '/builders' }),",
393
+ '});',
394
+ ].join('\n'));
395
+ assert.equal(gates.get('requireSecondKeyPage'), 'a.b', 'permission declared second still reads');
396
+ assert.equal(gates.has('requireNoPermissionPage'), false, 'a gate with no permission is dropped');
397
+ assert.equal(gates.get('requireNormalPage'), 'c.d', 'and it did not inherit a sibling\'s');
398
+ });
399
+
240
400
  // ONE-DIRECTIONAL, and deliberately so. A floor STRICTER than the route middleware
241
401
  // is legitimate and common: the wall lives in the handler (task.confirm.grade_bypass
242
402
  // on an any-builder POST /tasks/:id/confirm; goal.manage.own's owner|manager check;
@@ -437,5 +437,100 @@ test('the ship + claim skills both instruct relaying [otb-confirm]', () => {
437
437
  }
438
438
  });
439
439
 
440
+ // ---- grader OUTAGE vs quality rejection (task 1002892) ----
441
+ //
442
+ // stageFor() collapses every non-shipped outcome into 'failed', whose one
443
+ // presentation was red / 'Needs fixes' / 'grade failed'. An outage landed there
444
+ // too, so the card told a builder their code was REJECTED when nothing had judged
445
+ // it — on task 1002459 a logged-out CLI killed all four workers and the builder
446
+ // got a 0/10 card with an EMPTY issues[] and went hunting for defects that did not
447
+ // exist. These pin that an outage now reads as 'could not grade', in amber, with a
448
+ // retry — and, just as importantly, that a REAL failing grade is untouched.
449
+
450
+ const failedInput = (grade) => ({
451
+ postGradeStatus: 'completed', taskId: 1266, title: 'T', valueSummary: 'v',
452
+ fileCount: 1, added: 1, removed: 0, grade, credits: {}, usage: {}, brand: BRAND,
453
+ });
454
+ const PANEL_OUTAGE = { passed: false, issues: [], signals: { panel_outcome: 'unavailable' } };
455
+
456
+ test('outage: a total panel outage reads as could-not-grade, not needs-fixes', () => {
457
+ const m = card.buildCardModel(failedInput(PANEL_OUTAGE));
458
+ assert.equal(m.stage, 'failed', 'the STAGE is unchanged — only its presentation moves');
459
+ assert.equal(m.cfg.statusWord, 'Could not grade');
460
+ assert.equal(m.cfg.tone, 'warning', 'amber, not danger — nothing was rejected');
461
+ assert.match(m.cfg.headline(1266), /could not grade/);
462
+ });
463
+
464
+ test('outage: the next action is a retry, not Fix & re-grade', () => {
465
+ const m = card.buildCardModel(failedInput(PANEL_OUTAGE));
466
+ assert.equal(m.action.label, 'Try grading again');
467
+ assert.doesNotMatch(m.action.label, /Fix/, 'there is nothing to fix after an outage');
468
+ });
469
+
470
+ test('outage: the trail says not graded, not grade failed', () => {
471
+ const m = card.buildCardModel(failedInput(PANEL_OUTAGE));
472
+ const words = card.trailSteps(m.cfg).map((s) => s.word);
473
+ assert.ok(words.includes('not graded'), 'trail was ' + words.join(' -> '));
474
+ assert.ok(!words.includes('grade failed'));
475
+ });
476
+
477
+ test('outage: the Grade row and its chip say not graded, never a red fail', () => {
478
+ // Recoloring the banner alone left 'fail' in red one line below it, which is the
479
+ // same rejection signal in a smaller font. Both render paths read the same two
480
+ // values, so both are pinned here.
481
+ const m = card.buildCardModel(failedInput(PANEL_OUTAGE));
482
+ assert.equal(row(m, 'Grade'), 'not graded');
483
+ const chip = m.facts.find((f) => f.label === 'grade');
484
+ assert.equal(chip.value, 'not graded');
485
+ assert.notEqual(chip.cls, 'cc-bad', 'the grade chip must not be painted as a failure');
486
+ assert.doesNotMatch(card.renderMonospaceCard(m), /\bfail\b/, 'the word fail must not appear on an outage card');
487
+ });
488
+
489
+ test('outage with issues present still reports no grade, not a count', () => {
490
+ // An outage can carry incidental non-substantive issues; 'fail · 2 issues' would
491
+ // still be a verdict nothing produced.
492
+ const m = card.buildCardModel(failedInput({
493
+ passed: false,
494
+ issues: [{ severity: 'nit', summary: 'a' }, { severity: 'minor', summary: 'b' }],
495
+ signals: { panel_outcome: 'unavailable' },
496
+ }));
497
+ assert.equal(row(m, 'Grade'), 'not graded');
498
+ });
499
+
500
+ test('a REAL failing grade is untouched — still red, still Needs fixes', () => {
501
+ const m = card.buildCardModel(failedInput({ passed: false, issues: [{ severity: 'major', summary: 'x' }], signals: {} }));
502
+ assert.equal(m.cfg.statusWord, 'Needs fixes');
503
+ assert.equal(m.cfg.tone, 'danger');
504
+ assert.equal(m.action.label, 'Fix & re-grade');
505
+ assert.ok(card.trailSteps(m.cfg).map((s) => s.word).includes('grade failed'));
506
+ assert.equal(row(m, 'Grade'), 'fail · 1 issue', 'a real rejection still reports its verdict and count');
507
+ });
508
+
509
+ test('a grade carrying NO signals renders exactly as before (every old caller)', () => {
510
+ const m = card.buildCardModel(failedInput({ passed: false, issues: [] }));
511
+ assert.equal(m.cfg.statusWord, 'Needs fixes');
512
+ assert.equal(m.cfg.tone, 'danger');
513
+ });
514
+
515
+ test('gradeUnavailability: a cq-lens outage counts, but NOT beside a real finding', () => {
516
+ const cq = { cq_lens_unavailable: true };
517
+ assert.equal(card.gradeUnavailability({ signals: cq, rubric_scores: { code_quality: null }, issues: [] }), 'cq_lens');
518
+ assert.equal(
519
+ card.gradeUnavailability({ signals: cq, rubric_scores: { code_quality: null }, issues: [{ severity: 'blocker', summary: 'x' }] }),
520
+ null,
521
+ 'a dark lens beside a blocker is a rejection with a missing axis, not an outage');
522
+ assert.equal(card.gradeUnavailability({ signals: cq, rubric_scores: { code_quality: 7 }, issues: [] }), null,
523
+ 'a scored cq axis means the lens ran');
524
+ assert.equal(card.gradeUnavailability({}), null);
525
+ assert.equal(card.gradeUnavailability(null), null);
526
+ });
527
+
528
+ test('outage presentation never leaks onto a shipped or confirmed card', () => {
529
+ for (const status of ['shipped', 'confirmed']) {
530
+ const m = card.buildCardModel({ ...shippedInput, postGradeStatus: status, grade: { passed: true, issues: [], signals: { panel_outcome: 'unavailable' } } });
531
+ assert.notEqual(m.cfg.statusWord, 'Could not grade', status + ' must be unaffected');
532
+ }
533
+ });
534
+
440
535
  console.log(`\nship_card: ${passed} passed, ${failed} failed`);
441
536
  process.exit(failed ? 1 : 0);