@bongos/core 1.19.24 → 1.19.26

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,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.24",
6
- "core_contract": "1.19.24",
7
- "source_commit": "e17b55346ce6c5a5b0e7c17be12fd87f49805bfa",
5
+ "core_version": "1.19.26",
6
+ "core_contract": "1.19.26",
7
+ "source_commit": "522fdcf67202b1961ce21cc5bb1e9e21f3455105",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-11T01:49:44.456Z",
9
+ "built_at": "2026-08-11T13:25:13.567Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 285,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1204,
14
+ "functional_verbatim": 1205,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1510,
20
- "tree_sha256": "238b6875fb5bfeb460a7d827d8b671f23f910870fdfffaa09977138b1537865c",
19
+ "file_count": 1511,
20
+ "tree_sha256": "ecaa7b2c50ea700a1da02925b30bc638ccef3e362d27f47a50c59957afc232be",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -2822,7 +2822,7 @@
2822
2822
  {
2823
2823
  "path": "modules/grading/db.js",
2824
2824
  "mode": "0000644",
2825
- "sha256": "ed7df5654cead0c89945a53c2de65bbdb7549aeeb7b73845143bced5a451d77b"
2825
+ "sha256": "b651b73c65b0da33284430b430638dbc39b34db43846a05ab431deaabcc44b3b"
2826
2826
  },
2827
2827
  {
2828
2828
  "path": "modules/grading/grader-rubric.json",
@@ -2932,7 +2932,7 @@
2932
2932
  {
2933
2933
  "path": "modules/hall-ui/public/builders.js",
2934
2934
  "mode": "0000644",
2935
- "sha256": "dcb19e4ec7a5df5299f84d70dc80fcc09d0ecf3110d89dda9ec40d1444df376d"
2935
+ "sha256": "5bdd907f10ae15f8efdb55adce3ce23991a376122eb79c549e025f8f4845fae5"
2936
2936
  },
2937
2937
  {
2938
2938
  "path": "modules/hall-ui/public/chime.wav",
@@ -3352,7 +3352,7 @@
3352
3352
  {
3353
3353
  "path": "modules/lifecycle/db.js",
3354
3354
  "mode": "0000644",
3355
- "sha256": "7dc50e4e88a204583c753555c76f4103df405a143c4266dbff53793c292803f9"
3355
+ "sha256": "85c856016e548399538734098d2f08d3920fb52814759ffa314ed26d98bf32eb"
3356
3356
  },
3357
3357
  {
3358
3358
  "path": "modules/lifecycle/done-when.js",
@@ -3362,7 +3362,7 @@
3362
3362
  {
3363
3363
  "path": "modules/lifecycle/github-push.js",
3364
3364
  "mode": "0000644",
3365
- "sha256": "200de3b5be6b1f3b0612dea91409f44aa3d5b76f8f90fb71e4e5ced43a0890fb"
3365
+ "sha256": "c8f356746096a6fe39e456b12367d29b6bbf4e39df1515c09144c95e239f1b49"
3366
3366
  },
3367
3367
  {
3368
3368
  "path": "modules/lifecycle/goal-advisory.js",
@@ -3417,7 +3417,7 @@
3417
3417
  {
3418
3418
  "path": "modules/lifecycle/publish-reconciler.js",
3419
3419
  "mode": "0000644",
3420
- "sha256": "7f42dfe5b16dff0e3833b0a586c5b04592aeaee1aa1f4f41c0255a16db1a14fc"
3420
+ "sha256": "964af2331007a4cfb9142e7245d7625cf21c973e4f885e0171855b9e188b9e95"
3421
3421
  },
3422
3422
  {
3423
3423
  "path": "modules/lifecycle/routes/analytics.js",
@@ -3457,7 +3457,7 @@
3457
3457
  {
3458
3458
  "path": "modules/lifecycle/routes/tasks.js",
3459
3459
  "mode": "0000644",
3460
- "sha256": "add6acd00adc842e90348ca789da0a473aa2dc68d949cf2b5586c68ef5eaa1be"
3460
+ "sha256": "f5041a43ea0a74bc6b16f8ceb6490c70090e7959e66f137c92040ee842db1ec0"
3461
3461
  },
3462
3462
  {
3463
3463
  "path": "modules/lifecycle/routes/versions.js",
@@ -4002,12 +4002,12 @@
4002
4002
  {
4003
4003
  "path": "package-lock.json",
4004
4004
  "mode": "0000644",
4005
- "sha256": "642ec17a7c466b31700ea4759e0f3f3f1622f016bd5ce39122ac50ae1b393449"
4005
+ "sha256": "08c0827545637895b730387d92d4a0ce7fef7b055a966cb88ab70e6be901880c"
4006
4006
  },
4007
4007
  {
4008
4008
  "path": "package.json",
4009
4009
  "mode": "0000644",
4010
- "sha256": "00411692affd964ada475f325a9e810cd2d351d5b168500d32759a70ef2af627"
4010
+ "sha256": "1ac1c894e7f11d5502855775e3f2ca37031927a68ec7e8dca447661b872467e5"
4011
4011
  },
4012
4012
  {
4013
4013
  "path": "public-docs/index.html",
@@ -5197,7 +5197,7 @@
5197
5197
  {
5198
5198
  "path": "scripts/gds/ship.js",
5199
5199
  "mode": "0000644",
5200
- "sha256": "085a7feeff06fa7d2b833c483795161e2c4a6ee79630e2f09d7ae2f06eeb253c"
5200
+ "sha256": "1a00cb0c8ab216bab06b60b4ad1eaa18dc521eafd62ebe34f8fd35c7ff12e486"
5201
5201
  },
5202
5202
  {
5203
5203
  "path": "scripts/gds/smoke-batch-claim.sh",
@@ -5627,7 +5627,7 @@
5627
5627
  {
5628
5628
  "path": "src/module-api.js",
5629
5629
  "mode": "0000644",
5630
- "sha256": "08c66b410a4563d54fa8e8f6c0cf0ab077bf95b902923eb785419e8f261ebe99"
5630
+ "sha256": "39b05d86d4fbb1035769ff0c30a97b21c75eea8ee145a25784789d17fb20aa38"
5631
5631
  },
5632
5632
  {
5633
5633
  "path": "src/module-loader/loader.js",
@@ -6387,7 +6387,7 @@
6387
6387
  {
6388
6388
  "path": "tests/github_push_land_proof.mjs",
6389
6389
  "mode": "0000644",
6390
- "sha256": "080a19cae0faf168b4b7fa31d1a68d0fe84b3d5b41c9bbfda1f5ecee34ad8ca4"
6390
+ "sha256": "c55433099e4681a8dd091873e9f8687f35e629b4a4ad3a3c3dd462860070fc59"
6391
6391
  },
6392
6392
  {
6393
6393
  "path": "tests/github_push_merge_driver.mjs",
@@ -6564,6 +6564,11 @@
6564
6564
  "mode": "0000644",
6565
6565
  "sha256": "a2d76ca391999e54b0767c3cba8eba34e84d5c704c3ac98370195ef9889bd152"
6566
6566
  },
6567
+ {
6568
+ "path": "tests/grade_aggregates_unavailable.mjs",
6569
+ "mode": "0000644",
6570
+ "sha256": "a55ae09e94648035479ff214ac26c2c84f2fc3c166237f06d296a6fd6608ec48"
6571
+ },
6567
6572
  {
6568
6573
  "path": "tests/grade_attempts.mjs",
6569
6574
  "mode": "0000644",
@@ -7097,7 +7102,7 @@
7097
7102
  {
7098
7103
  "path": "tests/publish_branch_route.mjs",
7099
7104
  "mode": "0000644",
7100
- "sha256": "5f9a083a4fc9c54683a277afcd14aff10db0dda1082e65cb5032445b25026139"
7105
+ "sha256": "99572892490e209835aae6479370e97b49585bfaa719c788a4d06d92407a9a7d"
7101
7106
  },
7102
7107
  {
7103
7108
  "path": "tests/publish_manifest.mjs",
@@ -7107,7 +7112,7 @@
7107
7112
  {
7108
7113
  "path": "tests/publish_reconciler.mjs",
7109
7114
  "mode": "0000644",
7110
- "sha256": "fa54fe19a54153ce65f8a98ccce22c9aad7e2c928a648fb2fc621067168eb703"
7115
+ "sha256": "240337de03354de198d6baaaca9f33b267aceb40a8ba43d31303eb21fad9226c"
7111
7116
  },
7112
7117
  {
7113
7118
  "path": "tests/rank_gate.mjs",
@@ -7377,7 +7382,7 @@
7377
7382
  {
7378
7383
  "path": "tests/ship_ci_deploy.mjs",
7379
7384
  "mode": "0000644",
7380
- "sha256": "de59e8ed0d1a0e30b52994fe1bf3e1864706613a1329d967f74eeae277c1948e"
7385
+ "sha256": "e236bc3d6882bc6ddf5d389371a92a96aec62769f14c37bbca31810bcbea0d61"
7381
7386
  },
7382
7387
  {
7383
7388
  "path": "tests/ship_preflight.mjs",
@@ -18,22 +18,33 @@
18
18
  const api = require('../../src/module-api');
19
19
  const { pool } = api;
20
20
 
21
- // Rolling grade stats for a builder over the last `days`: { n, avg_score, n_passed }.
22
- // Windows by graded_at; `shipped_by` attributes the grade to the builder who did
23
- // the work. Returns a zero row when the builder has no grades in the window.
21
+ // Rolling grade stats for a builder over the last `days`: { n, avg_score,
22
+ // n_passed, n_unavailable }. Windows by graded_at; `shipped_by` attributes the
23
+ // grade to the builder who did the work. Returns a zero row when the builder
24
+ // has no grades in the window.
25
+ //
26
+ // Quality stats count ONLY quality-graded rows (task 1002652): a grader outage
27
+ // (#943 — signals.panel_outcome='unavailable') or a json-null gating axis
28
+ // (functional_fidelity / code_quality) is bucketed as n_unavailable, never a
29
+ // 0-score fail. The predicate is a documented twin of lifecycle/db.js
30
+ // `gradeQualityWhere` (module boundary — keep the two in sync).
24
31
  async function builderRollingAvgGrade(builderId, days = 14) {
32
+ const q = `(COALESCE(g.signals->>'panel_outcome','') <> 'unavailable'
33
+ AND COALESCE(jsonb_typeof(g.rubric_scores->'functional_fidelity'),'') <> 'null'
34
+ AND COALESCE(jsonb_typeof(g.rubric_scores->'code_quality'),'') <> 'null')`;
25
35
  const { rows } = await pool.query(
26
36
  `SELECT
27
- COUNT(*)::int AS n,
28
- COALESCE(AVG(g.score), 0)::numeric(3,1) AS avg_score,
29
- COALESCE(SUM(CASE WHEN g.passed THEN 1 ELSE 0 END), 0)::int AS n_passed
37
+ COUNT(*) FILTER (WHERE ${q})::int AS n,
38
+ COALESCE(AVG(g.score) FILTER (WHERE ${q}), 0)::numeric(3,1) AS avg_score,
39
+ COALESCE(SUM(CASE WHEN g.passed THEN 1 ELSE 0 END) FILTER (WHERE ${q}), 0)::int AS n_passed,
40
+ COUNT(*) FILTER (WHERE NOT ${q})::int AS n_unavailable
30
41
  FROM task_grades g
31
42
  JOIN tasks t ON t.id = g.task_id
32
43
  WHERE t.shipped_by = $1
33
44
  AND g.graded_at >= now() - ($2 || ' days')::interval`,
34
45
  [builderId, String(days)]
35
46
  );
36
- return rows[0] || { n: 0, avg_score: 0, n_passed: 0 };
47
+ return rows[0] || { n: 0, avg_score: 0, n_passed: 0, n_unavailable: 0 };
37
48
  }
38
49
 
39
50
  module.exports = { builderRollingAvgGrade };
@@ -1457,15 +1457,20 @@
1457
1457
  return;
1458
1458
  }
1459
1459
  tbody.innerHTML = grades.map((g) => {
1460
- const score = Number(g.score).toFixed(1);
1461
- const verdict = g.passed
1462
- ? `<span style="color:var(--olive,#5b7a4d);font-weight:600">passed</span>`
1463
- : `<span style="color:#a04040;font-weight:600">fell short</span>`;
1460
+ // A grader OUTAGE row (panel_outcome 'unavailable') carries a placeholder
1461
+ // score of 0 — label it, never render it as a 0.0 fail (task 1002652).
1462
+ const isOutage = g.panel_outcome === 'unavailable';
1463
+ const score = isOutage ? '—' : Number(g.score).toFixed(1);
1464
+ const verdict = isOutage
1465
+ ? `<span style="color:var(--ink-soft,#8a8577)">not graded (grader outage)</span>`
1466
+ : g.passed
1467
+ ? `<span style="color:var(--olive,#5b7a4d);font-weight:600">passed</span>`
1468
+ : `<span style="color:#a04040;font-weight:600">fell short</span>`;
1464
1469
  return `
1465
1470
  <tr>
1466
1471
  <td class="ledger__num">#${g.task_id}</td>
1467
1472
  <td>${escapeHtml(g.title || '')}</td>
1468
- <td class="ledger__drachmae">${score} / 10</td>
1473
+ <td class="ledger__drachmae">${isOutage ? '—' : `${score} / 10`}</td>
1469
1474
  <td>${verdict}</td>
1470
1475
  <td class="ledger__date">${fmtDate(g.graded_at)}</td>
1471
1476
  </tr>
@@ -1603,7 +1608,9 @@
1603
1608
  if (!isEco) {
1604
1609
  charts.push(chartCard('Cost / work ($)', (data.cost_trend || []).map((r) => r.per_work), 'var(--terracotta)',
1605
1610
  `total ${usd(h.total_cost_usd)}`));
1606
- charts.push(chartCard('Grade pass rate', (data.grade_trend || []).map((r) => (r.n ? r.n_passed / r.n : 0)), 'var(--accent)',
1611
+ // n counts quality-graded rows only (task 1002652); a day whose grades
1612
+ // were all outages has n=0 and must not plot as a 0% dip.
1613
+ charts.push(chartCard('Grade pass rate', (data.grade_trend || []).filter((r) => Number(r.n) > 0).map((r) => r.n_passed / r.n), 'var(--accent)',
1607
1614
  'share of work passing review'));
1608
1615
  }
1609
1616
  gridEl.innerHTML = charts.join('');
@@ -83,15 +83,34 @@ const staleClaimHourPredicate = (param) =>
83
83
  AND c.claimed_at < now() - (${param}::int * interval '1 hour'))`;
84
84
 
85
85
  // grade pass-count fragment: SUM of passed-as-1. `col` is the (possibly
86
- // alias-qualified) boolean column ('passed' or 'g.passed'); pass
87
- // { coalesce: false } for the two sites that omit the COALESCE(...,0) wrap
88
- // (audit §2B db-tx — alias × coalesce are the two preserved drift dimensions).
89
-
90
- const gradePassCount = (col, { coalesce = true } = {}) => {
91
- const inner = `SUM(CASE WHEN ${col} THEN 1 ELSE 0 END)`;
86
+ // alias-qualified) boolean column ('passed' or 'g.passed'); { where } restricts
87
+ // the sum to quality-graded rows via FILTER (task 1002652 — every aggregate
88
+ // site passes gradeQualityWhere, and keeps coalesce on because FILTER can zero
89
+ // out a group). { coalesce: false } preserved for callers that want a raw SUM.
90
+
91
+ const gradePassCount = (col, { coalesce = true, where = null } = {}) => {
92
+ const filter = where ? ` FILTER (WHERE ${where})` : '';
93
+ const inner = `SUM(CASE WHEN ${col} THEN 1 ELSE 0 END)${filter}`;
92
94
  return coalesce ? `COALESCE(${inner}, 0)::int` : `${inner}::int`;
93
95
  };
94
96
 
97
+ // Quality-graded row predicate (task 1002652). Two task_grades shapes carry
98
+ // score 0 / passed=false that are NOT quality verdicts and must never enter an
99
+ // aggregate: a grader OUTAGE (#943 — signals.panel_outcome='unavailable',
100
+ // rubric axes json-null) and a panel row whose gating axis
101
+ // (functional_fidelity / code_quality) is json-null (an errored worker, not a
102
+ // judged 0). jsonb_typeof + COALESCE keep legacy rows counted (missing keys /
103
+ // SQL-NULL rubric_scores → typeof NULL → '' ≠ 'null') and keep the predicate
104
+ // NULL-safe under NOT (a bare NULL operand would drop rows from the
105
+ // n_unavailable bucket).
106
+
107
+ const gradeQualityWhere = (alias = '') => {
108
+ const a = alias ? `${alias}.` : '';
109
+ return `(COALESCE(${a}signals->>'panel_outcome','') <> 'unavailable'
110
+ AND COALESCE(jsonb_typeof(${a}rubric_scores->'functional_fidelity'),'') <> 'null'
111
+ AND COALESCE(jsonb_typeof(${a}rubric_scores->'code_quality'),'') <> 'null')`;
112
+ };
113
+
95
114
  // The shared `bounds`/`days` DAY_SERIES_CTE for the costSummary day-series
96
115
  // queries moved with costSummary into modules/economy/cost.js (BV1.R77 / ADR
97
116
  // 0093 §§1-2) — its only two callers left the monolith.
@@ -4856,9 +4875,13 @@ async function recentShippedTasksByBuilder(builderId, limit = 10) {
4856
4875
  // this is safe to call freely.
4857
4876
 
4858
4877
  async function recentGradesForBuilder(builderId, limit = 5) {
4878
+ // panel_outcome rides along so consumers can LABEL an outage row ("not
4879
+ // graded") instead of rendering its placeholder 0.0 as a fail (task 1002652).
4880
+ // A list read stays unfiltered — honesty here means labeling, not hiding.
4859
4881
  const { rows } = await pool.query(
4860
4882
  `SELECT g.task_id, g.score, g.passed, g.threshold, g.graded_at,
4861
4883
  g.grader_kind, g.grader_version, g.rubric_scores,
4884
+ g.signals->>'panel_outcome' AS panel_outcome,
4862
4885
  COALESCE(g.signals->'issues', '[]'::jsonb) AS issues,
4863
4886
  COALESCE((g.signals->>'confidence')::numeric, NULL) AS confidence,
4864
4887
  t.title, t.kind, t.discipline
@@ -4876,33 +4899,43 @@ async function recentGradesForBuilder(builderId, limit = 5) {
4876
4899
  // public status tile + leaderboard hover.
4877
4900
 
4878
4901
  async function rollingAvgGrade(days = 7) {
4902
+ // Quality stats aggregate ONLY quality-graded rows; outage / null-axis rows
4903
+ // are counted apart as n_unavailable (task 1002652 — 21 outage rows once
4904
+ // read as a month of pass_rate 0.000).
4905
+ const q = gradeQualityWhere();
4879
4906
  const { rows } = await pool.query(
4880
4907
  `SELECT
4881
- COUNT(*)::int AS n,
4882
- COALESCE(AVG(score), 0)::numeric(3,1) AS avg_score,
4883
- ${gradePassCount('passed')} AS n_passed,
4884
- COALESCE(MIN(score), 0)::numeric(3,1) AS min_score,
4885
- COALESCE(MAX(score), 0)::numeric(3,1) AS max_score
4908
+ COUNT(*) FILTER (WHERE ${q})::int AS n,
4909
+ COALESCE(AVG(score) FILTER (WHERE ${q}), 0)::numeric(3,1) AS avg_score,
4910
+ ${gradePassCount('passed', { where: q })} AS n_passed,
4911
+ COALESCE(MIN(score) FILTER (WHERE ${q}), 0)::numeric(3,1) AS min_score,
4912
+ COALESCE(MAX(score) FILTER (WHERE ${q}), 0)::numeric(3,1) AS max_score,
4913
+ COUNT(*) FILTER (WHERE NOT ${q})::int AS n_unavailable
4886
4914
  FROM task_grades
4887
4915
  WHERE graded_at >= now() - ($1 || ' days')::interval`,
4888
4916
  [String(days)]
4889
4917
  );
4890
- return rows[0] || { n: 0, avg_score: 0, n_passed: 0, min_score: 0, max_score: 0 };
4918
+ return rows[0] || { n: 0, avg_score: 0, n_passed: 0, min_score: 0, max_score: 0, n_unavailable: 0 };
4891
4919
  }
4892
4920
 
4893
4921
  // Per-builder rolling avg, last `days` days. Powers the /builders profile.
4894
4922
 
4895
4923
  async function gradeByBuilder(days = 30) {
4924
+ // pass_rate is NULL (not 0.000) for a builder whose window holds only
4925
+ // outage / null-axis rows — "no graded work", never a fabricated fail-streak
4926
+ // (task 1002652). fmtPct-style consumers already render null as '—'.
4927
+ const q = gradeQualityWhere('g');
4896
4928
  const { rows } = await pool.query(
4897
4929
  `SELECT
4898
4930
  t.shipped_by AS builder_id,
4899
4931
  b.github_login,
4900
4932
  b.display_name,
4901
4933
  b.avatar_url,
4902
- COUNT(*)::int AS n,
4903
- COALESCE(AVG(g.score), 0)::numeric(3,1) AS avg_score,
4904
- ${gradePassCount('g.passed')} AS n_passed,
4905
- ROUND(COALESCE(AVG(CASE WHEN g.passed THEN 1 ELSE 0 END), 0), 3) AS pass_rate,
4934
+ COUNT(*) FILTER (WHERE ${q})::int AS n,
4935
+ COALESCE(AVG(g.score) FILTER (WHERE ${q}), 0)::numeric(3,1) AS avg_score,
4936
+ ${gradePassCount('g.passed', { where: q })} AS n_passed,
4937
+ ROUND(AVG(CASE WHEN g.passed THEN 1 ELSE 0 END) FILTER (WHERE ${q}), 3) AS pass_rate,
4938
+ COUNT(*) FILTER (WHERE NOT ${q})::int AS n_unavailable,
4906
4939
  MAX(g.graded_at) AS last_graded_at
4907
4940
  FROM task_grades g
4908
4941
  JOIN tasks t ON t.id = g.task_id
@@ -4920,12 +4953,17 @@ async function gradeByBuilder(days = 30) {
4920
4953
  // chart on status.amazonprimea.com.
4921
4954
 
4922
4955
  async function gradeTrendByDay(days = 30) {
4956
+ // A day whose rows are all outages keeps its row (n=0, avg_score NULL,
4957
+ // n_unavailable>0) so a chart can show "grader down", never a 0.0-score day
4958
+ // (task 1002652 — the 2026-07-12..19 zero-score week was five outage days).
4959
+ const q = gradeQualityWhere();
4923
4960
  const { rows } = await pool.query(
4924
4961
  `SELECT
4925
4962
  to_char(date_trunc('day', graded_at), 'YYYY-MM-DD') AS day,
4926
- COUNT(*)::int AS n,
4927
- AVG(score)::numeric(3,1) AS avg_score,
4928
- ${gradePassCount('passed', { coalesce: false })} AS n_passed
4963
+ COUNT(*) FILTER (WHERE ${q})::int AS n,
4964
+ AVG(score) FILTER (WHERE ${q})::numeric(3,1) AS avg_score,
4965
+ ${gradePassCount('passed', { where: q })} AS n_passed,
4966
+ COUNT(*) FILTER (WHERE NOT ${q})::int AS n_unavailable
4929
4967
  FROM task_grades
4930
4968
  WHERE graded_at >= now() - ($1 || ' days')::interval
4931
4969
  GROUP BY date_trunc('day', graded_at)
@@ -4989,11 +5027,14 @@ async function builderAnalytics(builderId, days = 90) {
4989
5027
  [builderId, d]
4990
5028
  ),
4991
5029
  // Grade pass-rate per day (quality holding up as throughput rises?).
5030
+ // Quality-graded rows only — outage / null-axis rows would read as
5031
+ // fail-days (task 1002652); headline.grade_pass_rate derives from these
5032
+ // rows so it inherits the same honesty.
4992
5033
  pool.query(
4993
5034
  `SELECT to_char(date_trunc('day', g.graded_at), 'YYYY-MM-DD') AS day,
4994
- COUNT(*)::int AS n,
4995
- ${gradePassCount('g.passed', { coalesce: false })} AS n_passed,
4996
- AVG(g.score)::numeric(3,1) AS avg_score
5035
+ COUNT(*) FILTER (WHERE ${gradeQualityWhere('g')})::int AS n,
5036
+ ${gradePassCount('g.passed', { where: gradeQualityWhere('g') })} AS n_passed,
5037
+ AVG(g.score) FILTER (WHERE ${gradeQualityWhere('g')})::numeric(3,1) AS avg_score
4997
5038
  FROM task_grades g
4998
5039
  JOIN tasks t ON t.id = g.task_id
4999
5040
  WHERE t.shipped_by = $1
@@ -5457,6 +5498,7 @@ module.exports = {
5457
5498
  rollingAvgGrade,
5458
5499
  gradeByBuilder,
5459
5500
  gradeTrendByDay,
5501
+ gradeQualityWhere, // exported for tests/grade_aggregates_unavailable.mjs (task 1002652)
5460
5502
  builderAnalytics,
5461
5503
  ecosystemAnalytics,
5462
5504
  createOverrideRequest,
@@ -465,6 +465,54 @@ function prReferencesTask(status, taskId) {
465
465
  return new RegExp(`\\btask\\s+#?${Number(taskId)}\\b`, 'i').test(hay);
466
466
  }
467
467
 
468
+ // task 1002645 — locate a task's PR by the "task <id>: …" TITLE convention, for when
469
+ // the reconciler cannot derive the branch. The sweep derives claude/<worktree_name>
470
+ // (the dev-box convention), but a laptop/wave ship pushes its own branch name
471
+ // (worktree-<worktree_name>), so the derived branch finds no PR and the sweep's whole
472
+ // PR path — the mergeIfGreen nudge (the ONLY green-PR merger where native auto-merge
473
+ // is repo-disallowed), conflict heal, land proof — never engages; the task strands at
474
+ // 'confirmed' (wave PRs 34/35/36). Both publish paths title their PR "task <id>: …"
475
+ // (openOrFindPullRequest above; ship.js buildPrCreateArgv), so a TITLE-PREFIX match
476
+ // positively binds a PR to its task. Deliberately TIGHTER than prReferencesTask
477
+ // (title+body word match): the search widens the candidate set from "this branch's
478
+ // own PR" to "recent PRs", and a body MENTION of a task must not bind it.
479
+ //
480
+ // Uses the REST pulls list (real-time — the search API lags on fresh PRs), newest
481
+ // first, and returns the first title match whose head is a same-repo, safe-named
482
+ // branch (a fork's head is invisible to the owner:branch lookups downstream):
483
+ // { branch, pr_number, pr_state, merged }. Null when nothing matches. Pure w.r.t.
484
+ // injected token/repoInfo/fetchImpl for tests.
485
+ async function findTaskPullRequest({ taskId }, deps = {}) {
486
+ if (!Number.isInteger(Number(taskId))) return null;
487
+ const token = await resolveToken(deps);
488
+ if (!token) throw err('PUSH_UNCONFIGURED');
489
+ const repoInfo = deps.repoInfo || loadRepoInfo();
490
+ if (!repoInfo || repoInfo.error) throw err('NO_REPO_INFO');
491
+ const { owner, repo } = repoInfo;
492
+ const doFetch = deps.fetchImpl || fetch;
493
+ const res = await doFetch(
494
+ `https://api.github.com/repos/${owner}/${repo}/pulls?state=all&sort=created&direction=desc&per_page=100`,
495
+ { headers: ghHeaders(token) }
496
+ );
497
+ if (!res.ok) throw err('STATUS_FETCH_FAILED', { status: res.status });
498
+ const prs = await res.json();
499
+ if (!Array.isArray(prs)) return null;
500
+ const titleRe = new RegExp(`^task\\s+#?${Number(taskId)}\\b`, 'i');
501
+ for (const pr of prs) {
502
+ if (!pr || !titleRe.test(String(pr.title || ''))) continue;
503
+ const head = pr.head || {};
504
+ if (!head.repo || head.repo.full_name !== `${owner}/${repo}`) continue;
505
+ if (!isSafeBranch(head.ref)) continue;
506
+ return {
507
+ branch: head.ref,
508
+ pr_number: pr.number || null,
509
+ pr_state: pr.state || null,
510
+ merged: !!pr.merged_at,
511
+ };
512
+ }
513
+ return null;
514
+ }
515
+
468
516
  // ---------------------------------------------------------------------------
469
517
  // Active merge driver (task 1162) — land a green PR the moment it's mergeable
470
518
  // instead of waiting on GitHub's auto-merge daemon.
@@ -1000,6 +1048,7 @@ module.exports = {
1000
1048
  landConfirmed,
1001
1049
  landProvenForTask,
1002
1050
  prReferencesTask,
1051
+ findTaskPullRequest,
1003
1052
  scrubCredential,
1004
1053
  isAncestorOfDefaultBranch,
1005
1054
  resolveToken,
@@ -240,20 +240,14 @@ async function runSweep(deps = {}) {
240
240
  }
241
241
  return flagged;
242
242
  };
243
- for (const r of rows) {
244
- const branch = r.worktree_name ? `claude/${r.worktree_name}` : null;
245
- if (branch) branchesSeen.add(branch);
246
- const branchUsable = !!branch && push.isSafeBranch(branch) && branchCount.get(branch) === 1;
247
- if (branch && branchCount.get(branch) > 1) {
248
- // A reused worktree_name maps a branch to >1 confirmed task, so a merged PR is
249
- // ambiguous — but each task's OWN head_sha is not, so we skip only the branch/PR
250
- // path and let the head_sha ancestry land-check below resolve it unambiguously.
251
- logFn(`[publish-reconciler] task ${r.task_id}: branch ${branch} maps to >1 confirmed task — using head_sha land-check only`);
252
- }
253
243
 
254
- let landed = false;
255
- let landNote = '';
256
- let branchStatus = null;
244
+ // task 1002645 — the per-branch PR path (merge nudge → conflict/stale heal →
245
+ // publish-status), extracted so it can run TWICE for one task: first on the
246
+ // derived claude/<worktree_name> branch (the dev-box convention), then — when
247
+ // that finds no PR — on the REAL branch adopted from the task's own PR
248
+ // (findTaskPullRequest). Mutates the sweep counters via closure; returns
249
+ // { status, prNotLanded }.
250
+ const sweepBranchPath = async (r, branch) => {
257
251
  // task 1002472 — set when a PR exists but has not fully landed. Classifies the
258
252
  // unprovable reason for the observability below (which the old bare `continue`
259
253
  // skipped). task 1002615: it no longer suppresses the land proof — the proof
@@ -261,8 +255,7 @@ async function runSweep(deps = {}) {
261
255
  // so an open PR still can't ship by reference, while a task whose WORK is
262
256
  // already on main (out-of-band merge) can now prove it by ancestry.
263
257
  let prNotLanded = false;
264
-
265
- if (branchUsable) {
258
+ {
266
259
  // Active merge driver (task 1162): GitHub's auto-merge fires on check-completion
267
260
  // EVENTS, so a PR whose auto-merge was re-armed AFTER its checks already went
268
261
  // green can sit open forever (the PR #125 stall). Before checking for a land,
@@ -399,7 +392,53 @@ async function runSweep(deps = {}) {
399
392
  // short-circuit that window.
400
393
  prNotLanded = true;
401
394
  }
402
- branchStatus = status;
395
+ return { status, prNotLanded };
396
+ }
397
+ };
398
+
399
+ for (const r of rows) {
400
+ let branch = r.worktree_name ? `claude/${r.worktree_name}` : null;
401
+ if (branch) branchesSeen.add(branch);
402
+ const branchUsable = !!branch && push.isSafeBranch(branch) && branchCount.get(branch) === 1;
403
+ if (branch && branchCount.get(branch) > 1) {
404
+ // A reused worktree_name maps a branch to >1 confirmed task, so a merged PR is
405
+ // ambiguous — but each task's OWN head_sha is not, so we skip only the branch/PR
406
+ // path and let the head_sha ancestry land-check below resolve it unambiguously.
407
+ logFn(`[publish-reconciler] task ${r.task_id}: branch ${branch} maps to >1 confirmed task — using head_sha land-check only`);
408
+ }
409
+
410
+ let landed = false;
411
+ let landNote = '';
412
+ let branchStatus = null;
413
+ let prNotLanded = false;
414
+
415
+ if (branchUsable) {
416
+ ({ status: branchStatus, prNotLanded } = await sweepBranchPath(r, branch));
417
+ }
418
+
419
+ // task 1002645 — the claude/<worktree_name> derivation is the DEV-BOX convention;
420
+ // a laptop/wave ship pushes its own branch name (worktree-<worktree_name>), so the
421
+ // derived branch finds no PR and the merge nudge / conflict heal / land proof all
422
+ // sat idle while the task stranded at 'confirmed' (wave PRs 34/35/36 — native
423
+ // auto-merge is repo-disallowed, so the sweep's mergeIfGreen is the only green-PR
424
+ // merger). When the derived branch yields no PR, find the task's PR by the
425
+ // "task <id>: …" TITLE convention and re-run the branch path on its real head
426
+ // branch. Best-effort: a search miss just waits for the next sweep.
427
+ if ((!branchStatus || branchStatus.pr_state === 'none')
428
+ && typeof push.findTaskPullRequest === 'function') {
429
+ try {
430
+ const found = await push.findTaskPullRequest({ taskId: r.task_id });
431
+ if (found && found.branch && found.branch !== branch && push.isSafeBranch(found.branch)) {
432
+ logFn(
433
+ `[publish-reconciler] task ${r.task_id}: no PR on ${branch || '(no derived branch)'} — adopted ${found.branch} from PR #${found.pr_number} by title convention`
434
+ );
435
+ branch = found.branch;
436
+ branchesSeen.add(branch);
437
+ ({ status: branchStatus, prNotLanded } = await sweepBranchPath(r, branch));
438
+ }
439
+ } catch (_) {
440
+ /* best-effort: a PR-search miss waits for the next sweep */
441
+ }
403
442
  }
404
443
 
405
444
  // task 1547 — prove the land belongs to THIS task before shipping. landConfirmed
@@ -1718,6 +1718,19 @@ module.exports = function buildTasksRouter() {
1718
1718
  if (!(await gateTaskOwnership(req, res, id))) return;
1719
1719
  const { head_sha: headSha } = req.body;
1720
1720
  if (!/^[0-9a-f]{7,64}$/.test(headSha)) return res.fail('bad_head_sha', 400);
1721
+ // task 1002645: the laptop ci-land pushes with the BUILDER's own credential, so
1722
+ // the server-mediated path that records claims.published_head_sha (task 1547)
1723
+ // never runs — landProvenForTask then has neither tip nor PR and the reconciler
1724
+ // strands the task at 'confirmed' forever (wave PRs 34/35/36). ship.js calls
1725
+ // this endpoint with the exact pushed tip at the moment of the push, so record
1726
+ // it here too. Sits BEFORE the credential 503 on purpose: the tip is true
1727
+ // regardless, and a later-provisioned credential lets the sweep heal
1728
+ // retroactively. Best-effort, mirroring publish-branch.
1729
+ try {
1730
+ await db.setClaimPublishedSha({ taskId: id, builderId: req.builder.id, sha: headSha });
1731
+ } catch (e) {
1732
+ console.error('[gds] setClaimPublishedSha via attest-gate (non-blocking)', id, e && e.message);
1733
+ }
1721
1734
  if (!githubPush.isConfigured()) {
1722
1735
  return res.fail('push_unconfigured', { status: 503, message: 'No server push credential (GitHub App / GITHUB_PUSH_TOKEN) to post the gate attestation. ' +
1723
1736
  'A gate-surface PR will need an owner review until the operator provisions it.' });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.24",
3
+ "version": "1.19.26",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.24",
9
+ "version": "1.19.26",
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.24",
3
+ "version": "1.19.26",
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",
@@ -1326,6 +1326,23 @@ function printLandPending(taskId, opts = {}) {
1326
1326
  for (const line of landPendingLines(taskId, opts)) console.log(line);
1327
1327
  }
1328
1328
 
1329
+ // task 1002645 — the honest close-out when the PR MERGED but the server refused the
1330
+ // confirmed→shipped flip (reconcile_blocked: the task-1547 land proof missed — a
1331
+ // wrong/missing published tip, or the matched PR doesn't reference this task). The
1332
+ // optimistic "NOT a failure" banner was a lie here: without proof the auto-land can
1333
+ // NEVER finish, and the wave-5 ships sat at 'confirmed' with their last signal
1334
+ // saying everything was fine. Name the refusal and the remedy instead. Pure +
1335
+ // exported so the wording is pinned by tests/ship_ci_deploy.mjs.
1336
+ function reconcileBlockedLines(taskId, reason) {
1337
+ return [
1338
+ ` ci-land: the PR MERGED, but the task did NOT flip to 'shipped' — the server could not prove the land is`,
1339
+ ` THIS task's: ${reason}.`,
1340
+ ` The server reconciler retries the proof every ~5 min, so a late-recorded tip or PR reference still lands`,
1341
+ ` on its own. Re-run \`node scripts/gds/ship.js ${taskId}\` to re-drive it; if it stays blocked the server`,
1342
+ ` flags the task needs_rebase and /builder-start will show the remedy.`,
1343
+ ];
1344
+ }
1345
+
1329
1346
  // One module-scope sleep command shared by both poll loops (task 1093 / C9). A
1330
1347
  // child `node -e setTimeout` is used (not `await new Promise`) so it blocks
1331
1348
  // identically in the SYNCHRONOUS pollCiDeploy and the async pollServerPublish —
@@ -1341,13 +1358,26 @@ const POLL_SLEEP_CMD = 'node -e "setTimeout(()=>{}, 10000)"';
1341
1358
  async function pollServerPublish(taskId, branch) {
1342
1359
  const TRIES = 54; // ~9 min at 10s — checks + auto-merge + Actions deploy
1343
1360
  console.log(' ci-land: waiting for auto-merge + prod deploy (polling the server)...');
1361
+ // task 1002645: GitHub can report merged+deployed while the SERVER refused the
1362
+ // confirmed→shipped flip (reconcile_blocked — the task-1547 land proof missed).
1363
+ // "Prod is live" + exit here was a lie that left the task stranded with no
1364
+ // signal. Say the real reason and keep polling — attest-gate now records the
1365
+ // pushed tip, so the proof usually heals within the window.
1366
+ let blockedReason = null;
1344
1367
  for (let i = 0; i < TRIES; i++) {
1345
1368
  const r = await (await cliClient()).tasks.getTasksIdPublishStatus({ id: taskId, query: { branch } });
1346
1369
  if (r.ok && r.data) {
1347
1370
  const outcome = classifyLandOutcome(r.data);
1348
1371
  if (outcome === 'shipped') {
1349
- console.log(` ci-land: merged (${String(r.data.merge_sha || '').slice(0, 8)}) + deploy-prod succeeded. Prod is live.`);
1350
- return landed();
1372
+ if (r.data.reconcile_blocked) {
1373
+ if (r.data.reconcile_blocked !== blockedReason) {
1374
+ blockedReason = r.data.reconcile_blocked;
1375
+ console.log(` ci-land: merged, but the server did NOT flip the task to shipped: ${blockedReason}. Still polling (the reconciler retries every sweep)...`);
1376
+ }
1377
+ } else {
1378
+ console.log(` ci-land: merged (${String(r.data.merge_sha || '').slice(0, 8)}) + deploy-prod succeeded. Prod is live.`);
1379
+ return landed();
1380
+ }
1351
1381
  }
1352
1382
  if (outcome === 'failed') {
1353
1383
  console.log(` ci-land: PR was CLOSED without merging (a required check failed, or it was closed manually). Fix it and re-run \`node scripts/gds/ship.js ${taskId}\` to resume. Task stays at 'confirmed'.`);
@@ -1356,6 +1386,11 @@ async function pollServerPublish(taskId, branch) {
1356
1386
  }
1357
1387
  shellOk(POLL_SLEEP_CMD);
1358
1388
  }
1389
+ if (blockedReason) {
1390
+ console.log('');
1391
+ for (const line of reconcileBlockedLines(taskId, blockedReason)) console.log(line);
1392
+ return landBailed(`the PR merged but the server could not prove the land is this task's: ${blockedReason}`, `node scripts/gds/ship.js ${taskId}`);
1393
+ }
1359
1394
  printLandPending(taskId);
1360
1395
  return landPending();
1361
1396
  }
@@ -4176,6 +4211,9 @@ module.exports = {
4176
4211
  // task 1002606: the success-pending banner lines, pure — pinned so the strand
4177
4212
  // detection story ("NOT a failure" + how you WILL find out if it fails) can't drift.
4178
4213
  landPendingLines,
4214
+ // task 1002645: the merged-but-proof-refused close-out lines (reconcile_blocked) —
4215
+ // pinned so this shape can never regress to the optimistic "NOT a failure" banner.
4216
+ reconcileBlockedLines,
4179
4217
  // #857: resolveDeployMode (env + committed config) so doctor.js reports the
4180
4218
  // active deploy mode exactly as ship will use it.
4181
4219
  resolveDeployMode,
package/src/module-api.js CHANGED
@@ -475,8 +475,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
475
475
  // landed since 1.19.22 with no explicit bump. run 31450197816. (task 1002620)
476
476
  // 1.19.24 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
477
477
  // landed since 1.19.23 with no explicit bump. run 31450459825. (task 1002620)
478
+ // 1.19.25 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
479
+ // landed since 1.19.24 with no explicit bump. run 31495418623. (task 1002620)
480
+ // 1.19.26 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
481
+ // landed since 1.19.25 with no explicit bump. run 31495996665. (task 1002620)
478
482
  // ---------------------------------------------------------------------------
479
- const CORE_VERSION = '1.19.24'; // CI auto-patch carrier (ADR 0161); see changelog above
483
+ const CORE_VERSION = '1.19.26'; // CI auto-patch carrier (ADR 0161); see changelog above
480
484
 
481
485
  // A namespaced logger so a module's log lines are attributable + consistent.
482
486
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -152,6 +152,95 @@ test('landProvenForTask: a non-sha published tip ("HEAD") falls back to the PR c
152
152
  assert.equal(compareCalled, false, 'a non-sha tip must never reach the compare API (would false-positive)');
153
153
  });
154
154
 
155
+ // --- task 1002645: findTaskPullRequest — the PR-title fallback locator --------
156
+ //
157
+ // The reconciler derives claude/<worktree_name> (dev-box convention); a laptop/wave
158
+ // ship pushes worktree-<worktree_name>, so the derived branch finds no PR and the
159
+ // sweep never engaged (wave PRs 34/35/36 stranded at 'confirmed'). This locates the
160
+ // task's PR by the "task <id>: …" TITLE-PREFIX convention — deliberately TIGHTER
161
+ // than prReferencesTask (title+body word match) because the search widens the
162
+ // candidate set from "this branch's own PR" to "recent PRs".
163
+
164
+ // A REST /pulls list response the locator pages through (newest first).
165
+ function depsWithPulls(pulls, { ok = true, status = 200 } = {}) {
166
+ return {
167
+ token: 'x',
168
+ repoInfo: { owner: 'o', repo: 'r' },
169
+ fetchImpl: async () => ({ ok, status, json: async () => pulls }),
170
+ };
171
+ }
172
+ const pull = (title, ref, { merged = false, state = 'closed', repo = 'o/r', number = 36 } = {}) => ({
173
+ number, title, state, merged_at: merged ? '2026-08-11T00:00:00Z' : null,
174
+ head: { ref, repo: { full_name: repo } },
175
+ });
176
+
177
+ test('findTaskPullRequest: "task <id>: …" title → returns the REAL head branch + PR meta', async () => {
178
+ const r = await gh.findTaskPullRequest(
179
+ { taskId: 1002616 },
180
+ depsWithPulls([
181
+ pull('task 1002620: unrelated newer', 'worktree-task-1002620'),
182
+ pull('task 1002616: denial speech', 'worktree-task-1002616', { merged: true }),
183
+ ])
184
+ );
185
+ assert.equal(r.branch, 'worktree-task-1002616');
186
+ assert.equal(r.merged, true);
187
+ assert.equal(r.pr_number, 36);
188
+ });
189
+
190
+ test('findTaskPullRequest: a body-only mention must NOT bind (tighter than prReferencesTask)', async () => {
191
+ const r = await gh.findTaskPullRequest(
192
+ { taskId: 55 },
193
+ depsWithPulls([{ ...pull('fix regression', 'other-branch'), body: 'this also touches task 55' }])
194
+ );
195
+ assert.equal(r, null, 'a PR that merely MENTIONS the task is not its PR');
196
+ });
197
+
198
+ test('findTaskPullRequest: word boundary — task 100 does not match "task 1002: …"', async () => {
199
+ const r = await gh.findTaskPullRequest(
200
+ { taskId: 100 },
201
+ depsWithPulls([pull('task 1002: other work', 'worktree-task-1002')])
202
+ );
203
+ assert.equal(r, null);
204
+ });
205
+
206
+ test('findTaskPullRequest: a FORK head is skipped (owner:branch lookups downstream cannot see it)', async () => {
207
+ const r = await gh.findTaskPullRequest(
208
+ { taskId: 7 },
209
+ depsWithPulls([pull('task 7: from a fork', 'their-branch', { repo: 'someone/else' })])
210
+ );
211
+ assert.equal(r, null);
212
+ });
213
+
214
+ test('findTaskPullRequest: an unsafe head ref is skipped (injection guard)', async () => {
215
+ const r = await gh.findTaskPullRequest(
216
+ { taskId: 8 },
217
+ depsWithPulls([pull('task 8: bad head', '-rf')])
218
+ );
219
+ assert.equal(r, null);
220
+ });
221
+
222
+ test('findTaskPullRequest: no match → null; bad task id → null before any fetch', async () => {
223
+ assert.equal(await gh.findTaskPullRequest({ taskId: 9 }, depsWithPulls([])), null);
224
+ assert.equal(
225
+ await gh.findTaskPullRequest(
226
+ { taskId: 'nope' },
227
+ { token: 'x', repoInfo: { owner: 'o', repo: 'r' }, fetchImpl: async () => { throw new Error('must not fetch'); } }
228
+ ),
229
+ null
230
+ );
231
+ });
232
+
233
+ test('findTaskPullRequest: API failure → STATUS_FETCH_FAILED; no credential → PUSH_UNCONFIGURED', async () => {
234
+ await assert.rejects(
235
+ () => gh.findTaskPullRequest({ taskId: 1 }, depsWithPulls([], { ok: false, status: 502 })),
236
+ (e) => e.code === 'STATUS_FETCH_FAILED'
237
+ );
238
+ await assert.rejects(
239
+ () => gh.findTaskPullRequest({ taskId: 1 }, { token: null, repoInfo: { owner: 'o', repo: 'r' } }),
240
+ (e) => e.code === 'PUSH_UNCONFIGURED'
241
+ );
242
+ });
243
+
155
244
  // --- scrubCredential ---------------------------------------------------------
156
245
 
157
246
  test('scrubCredential: strips the x-access-token push credential from captured output', () => {
@@ -0,0 +1,114 @@
1
+ // tests/grade_aggregates_unavailable.mjs
2
+ //
3
+ // Unit tests for task 1002652 — a grader OUTAGE row (#943: score 0, passed
4
+ // false, signals.panel_outcome='unavailable', rubric axes json-null) and a
5
+ // null-gating-axis panel row must never enter a quality aggregate. 21 outage
6
+ // rows once read as a month of pass_rate 0.000 on /public/grades and the hall.
7
+ //
8
+ // The SQL itself runs against live Postgres in production; here we pin the
9
+ // QUERY CONTRACT the same way the other pool.query readers are pinned (see
10
+ // versions_route.mjs note): pool.query is stubbed to capture SQL, and each
11
+ // aggregate's captured text must carry the quality predicate + the
12
+ // n_unavailable bucket. gradeQualityWhere's NULL-safety (COALESCE around every
13
+ // operand) is what keeps legacy rows counted AND the NOT-bucket total — a bare
14
+ // NULL operand under NOT would silently drop rows from both buckets.
15
+ //
16
+ // Run: node tests/grade_aggregates_unavailable.mjs
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { createRequire } from 'node:module';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const { pool } = require('../src/bongos/pool.js');
23
+ const db = require('../modules/lifecycle/db.js');
24
+ const gradingDb = require('../modules/grading/db.js');
25
+
26
+ let passed = 0;
27
+ let failed = 0;
28
+ async function test(name, fn) {
29
+ try { await fn(); passed++; console.log(` ok ${name}`); }
30
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
31
+ }
32
+
33
+ // Capture every SQL text pool.query sees while `fn` runs; always restore.
34
+ async function captureSql(fn) {
35
+ const captured = [];
36
+ const orig = pool.query;
37
+ pool.query = async (sql) => { captured.push(String(sql)); return { rows: [] }; };
38
+ try { await fn(); } finally { pool.query = orig; }
39
+ return captured;
40
+ }
41
+
42
+ const PREDICATE_MARKS = [
43
+ "panel_outcome", "'unavailable'",
44
+ "jsonb_typeof", "functional_fidelity", "code_quality",
45
+ ];
46
+
47
+ function assertQualityFiltered(sql, label) {
48
+ for (const mark of PREDICATE_MARKS) {
49
+ assert.ok(sql.includes(mark), `${label}: SQL must carry quality-predicate mark "${mark}"`);
50
+ }
51
+ assert.ok(sql.includes('FILTER (WHERE'), `${label}: aggregates must FILTER on the predicate`);
52
+ assert.ok(sql.includes('n_unavailable'), `${label}: must bucket excluded rows as n_unavailable`);
53
+ // NULL-safety: every jsonb_typeof operand must be COALESCE-wrapped, or NOT
54
+ // (predicate) goes NULL on legacy rows and drops them from n_unavailable.
55
+ const typeofCount = (sql.match(/jsonb_typeof/g) || []).length;
56
+ const guardedCount = (sql.match(/COALESCE\(jsonb_typeof/g) || []).length;
57
+ assert.equal(guardedCount, typeofCount, `${label}: every jsonb_typeof must be COALESCE-guarded`);
58
+ }
59
+
60
+ await test('gradeQualityWhere: parenthesized, alias-qualified, NULL-safe on all three operands', () => {
61
+ const bare = db.gradeQualityWhere();
62
+ const aliased = db.gradeQualityWhere('g');
63
+ assert.ok(bare.startsWith('(') && bare.trimEnd().endsWith(')'), 'must parenthesize (composes under NOT/AND)');
64
+ assert.ok(aliased.includes('g.signals') && aliased.includes('g.rubric_scores'), 'alias must qualify both columns');
65
+ assert.ok(!bare.includes('g.'), 'bare form must not smuggle an alias');
66
+ assert.ok(bare.includes("COALESCE(") && bare.includes("panel_outcome"), 'panel_outcome operand must be COALESCE-guarded');
67
+ assert.equal((bare.match(/COALESCE\(jsonb_typeof/g) || []).length, 2, 'both rubric axes COALESCE-guarded');
68
+ });
69
+
70
+ await test('rollingAvgGrade excludes outage/null-axis rows and buckets n_unavailable', async () => {
71
+ const sql = (await captureSql(() => db.rollingAvgGrade(7)))[0];
72
+ assertQualityFiltered(sql, 'rollingAvgGrade');
73
+ });
74
+
75
+ await test('gradeByBuilder: quality-filtered, and pass_rate is NULL (not 0.000) with no quality rows', async () => {
76
+ const sql = (await captureSql(() => db.gradeByBuilder(30)))[0];
77
+ assertQualityFiltered(sql, 'gradeByBuilder');
78
+ // The old shape ROUND(COALESCE(AVG(...),0),3) fabricated a 0.000 fail-streak
79
+ // for an all-outage window — the lars589 symptom. COALESCE must be gone from
80
+ // the pass_rate expression so it reads NULL ("no graded work") instead.
81
+ assert.ok(!sql.includes('ROUND(COALESCE(AVG'), 'pass_rate must not coalesce an empty window to 0');
82
+ assert.ok(/AVG\(CASE WHEN g\.passed THEN 1 ELSE 0 END\) FILTER/.test(sql), 'pass_rate must average quality rows only');
83
+ });
84
+
85
+ await test('gradeTrendByDay keeps outage-only days as n=0 + n_unavailable, never a 0.0-score day', async () => {
86
+ const sql = (await captureSql(() => db.gradeTrendByDay(30)))[0];
87
+ assertQualityFiltered(sql, 'gradeTrendByDay');
88
+ assert.ok(!/WHERE[^G]*unavailable[^G]*GROUP BY/s.test(sql.replace(/FILTER \(WHERE[^)]+\)+/g, '')),
89
+ 'exclusion must be FILTER-based (top-level WHERE would drop outage-only days from the series)');
90
+ });
91
+
92
+ await test('recentGradesForBuilder labels rather than hides: selects panel_outcome, no FILTER', async () => {
93
+ const sql = (await captureSql(() => db.recentGradesForBuilder(1, 5)))[0];
94
+ assert.ok(sql.includes("signals->>'panel_outcome'"), 'must select panel_outcome for display labeling');
95
+ assert.ok(!sql.includes('FILTER (WHERE'), 'the list read stays unfiltered — labeling, not hiding');
96
+ });
97
+
98
+ await test('builderAnalytics grade-trend query is quality-filtered (feeds headline.grade_pass_rate)', async () => {
99
+ const sqls = await captureSql(() => db.builderAnalytics(1, 90));
100
+ const gradeSql = sqls.find((s) => s.includes('task_grades') && s.includes('graded_at'));
101
+ assert.ok(gradeSql, 'builderAnalytics must query task_grades');
102
+ for (const mark of PREDICATE_MARKS) {
103
+ assert.ok(gradeSql.includes(mark), `builderAnalytics grade query must carry "${mark}"`);
104
+ }
105
+ assert.ok(gradeSql.includes('FILTER (WHERE'), 'builderAnalytics grade query must FILTER');
106
+ });
107
+
108
+ await test('grading/db builderRollingAvgGrade (the documented twin) carries the same predicate', async () => {
109
+ const sql = (await captureSql(() => gradingDb.builderRollingAvgGrade(1, 14)))[0];
110
+ assertQualityFiltered(sql, 'builderRollingAvgGrade');
111
+ });
112
+
113
+ console.log(`\n${passed} passed, ${failed} failed`);
114
+ process.exit(failed === 0 ? 0 : 1);
@@ -311,6 +311,44 @@ await t('POST /tasks/:id/attest-gate: requireBuilder present, allowBoxScope ABSE
311
311
  assert.ok(!handles.includes(auth.allowBoxScope), 'must NOT carry allowBoxScope — a box session must reauth, never self-attest (#919)');
312
312
  });
313
313
 
314
+ // task 1002645: the attest call is the ONE moment the laptop ci-land tells the
315
+ // server its true pushed tip, so the route must record it on the claim
316
+ // (claims.published_head_sha) — without it landProvenForTask has neither tip nor
317
+ // PR and the reconciler strands the ship at 'confirmed' forever (wave PRs
318
+ // 34/35/36). Recorded BEFORE the push-credential 503 on purpose: an unconfigured
319
+ // server still captures the tip, so a later-provisioned credential heals the
320
+ // strand retroactively — which is exactly the shape this test drives.
321
+ await t('POST /tasks/:id/attest-gate records the pushed tip on the claim, even when push is unconfigured', async () => {
322
+ const dbMod = require('../modules/lifecycle/db.js');
323
+ const savedSet = dbMod.setClaimPublishedSha;
324
+ const recorded = [];
325
+ dbMod.setClaimPublishedSha = async (args) => { recorded.push(args); return 1; };
326
+ try {
327
+ await withEnv({
328
+ GITHUB_PUSH_TOKEN: undefined, GITHUB_APP_ID: undefined,
329
+ GITHUB_APP_INSTALLATION_ID: undefined, GITHUB_APP_PRIVATE_KEY: undefined,
330
+ }, async () => {
331
+ const router = buildTasksRouter();
332
+ const layer = findRouteLayer(router, '/tasks/:id/attest-gate', 'post');
333
+ const handler = layer.route.stack[layer.route.stack.length - 1].handle;
334
+ const out = { failCode: null, jsonBody: null };
335
+ const res = {
336
+ status() { return this; },
337
+ json(b) { out.jsonBody = b; return this; },
338
+ fail(code) { out.failCode = code; return this; },
339
+ };
340
+ // rank archon → gateTaskOwnership passes with no DB lookup, keeping the test hermetic.
341
+ const req = { params: { id: '77' }, body: { head_sha: 'a'.repeat(40) }, builder: { id: 95, rank: 'archon' } };
342
+ await handler(req, res);
343
+ assert.equal(out.failCode, 'push_unconfigured', 'no credential → still the usual 503 to the caller');
344
+ assert.deepEqual(recorded, [{ taskId: 77, builderId: 95, sha: 'a'.repeat(40) }],
345
+ 'the tip is recorded BEFORE the credential check — the wave-strand cure');
346
+ });
347
+ } finally {
348
+ dbMod.setClaimPublishedSha = savedSet;
349
+ }
350
+ });
351
+
314
352
  // ===========================================================================
315
353
  // GitHub App credential (task 1028 / ADR 0055 hardening): the server mints a
316
354
  // short-lived installation token from the App private key instead of holding a
@@ -52,13 +52,14 @@ function mkDeps({
52
52
  statusByBranch = {},
53
53
  mergeByBranch = {},
54
54
  ancestryBySha = {},
55
+ taskPrByTask = {},
55
56
  shipImpl,
56
57
  broadcastImpl,
57
58
  conflictResolveImpl,
58
59
  refreshStaleImpl,
59
60
  healAttempts = new Map(),
60
61
  } = {}) {
61
- const calls = { ship: [], broadcast: [], status: [], merge: [], ancestry: [], resolve: [], refresh: [], flag: [], events: [], logs: [], errors: [] };
62
+ const calls = { ship: [], broadcast: [], status: [], merge: [], ancestry: [], search: [], resolve: [], refresh: [], flag: [], events: [], logs: [], errors: [] };
62
63
  // isSafeBranch mirrors github-push's pattern enough for the test (rejects
63
64
  // option-like / traversal refs); we use the REAL module so the contract can't drift.
64
65
  const githubPush = require('../modules/lifecycle/github-push.js');
@@ -99,6 +100,16 @@ function mkDeps({
99
100
  if (s instanceof Error) throw s;
100
101
  return s || NONE;
101
102
  },
103
+ // task 1002645: the PR-title fallback locator. Default: nothing found (null),
104
+ // so pre-1002645 tests keep their exact behavior. Script per task id via
105
+ // taskPrByTask; an Error value throws (to prove the sweep swallows a search
106
+ // miss and continues).
107
+ findTaskPullRequest: async ({ taskId }) => {
108
+ calls.search.push(taskId);
109
+ const f = taskPrByTask[taskId];
110
+ if (f instanceof Error) throw f;
111
+ return f || null;
112
+ },
102
113
  // task 1313 head_sha land-check. Default: not on main. An Error value throws
103
114
  // (to prove a transient compare-API miss is swallowed, leaving it for next sweep).
104
115
  isAncestorOfDefaultBranch: async ({ sha }) => {
@@ -1077,6 +1088,106 @@ test('1002606 the aged stale strand now ALSO emits task.strand-flagged', async (
1077
1088
  assert.match(strandEvents[0].payload.reason, /^strand:no_branch_no_tip/);
1078
1089
  });
1079
1090
 
1091
+ // ---------------------------------------------------------------------------
1092
+ // task 1002645 — the reconciler was blind to laptop/wave ships. The sweep derives
1093
+ // claude/<worktree_name> (dev-box convention); a wave ship pushes
1094
+ // worktree-<worktree_name>, so getPublishStatus saw pr_state:'none' and the whole
1095
+ // PR path (mergeIfGreen nudge — the ONLY merger where native auto-merge is
1096
+ // repo-disallowed — conflict heal, land proof) never engaged. PRs 34/35/36 all
1097
+ // stranded at 'confirmed' until a manual flip. The fallback finds the task's PR by
1098
+ // the "task <id>: …" TITLE convention and re-runs the branch path on its REAL head.
1099
+ // ---------------------------------------------------------------------------
1100
+
1101
+ test('1002645 THE PIN: a worktree-named branch (laptop wave ship) lands UNATTENDED via the PR-title fallback', async () => {
1102
+ const deps = mkDeps({
1103
+ rows: [{ task_id: 2645, builder_id: 95, worktree_name: 'task-2645', published_head_sha: ON_MAIN }],
1104
+ statusByBranch: {
1105
+ 'claude/task-2645': NONE, // the derived dev-box branch has no PR
1106
+ 'worktree-task-2645': LANDED_NO_WORKFLOW, // the REAL branch: merged, pin-bump repo
1107
+ },
1108
+ taskPrByTask: { 2645: { branch: 'worktree-task-2645', pr_number: 36, pr_state: 'closed', merged: true } },
1109
+ ancestryBySha: { [ON_MAIN]: true },
1110
+ });
1111
+ const r = await reconciler.runSweep(deps);
1112
+ assert.equal(r.reconciled, 1, 'the wave-5 strand shape must land with NO human step (ADR 0162)');
1113
+ assert.deepEqual(deps._calls.ship, [{ taskId: 2645, builderId: 95 }]);
1114
+ assert.deepEqual(deps._calls.search, [2645], 'the fallback ran exactly once');
1115
+ assert.ok(deps._calls.merge.includes('worktree-task-2645'),
1116
+ 'the merge nudge engages on the REAL branch — the only merger when native auto-merge is disallowed');
1117
+ });
1118
+
1119
+ test('1002645 a green-but-unmerged PR on the real branch is server-merged via the fallback (no client poll needed)', async () => {
1120
+ const deps = mkDeps({
1121
+ rows: [{ task_id: 2646, builder_id: 95, worktree_name: 'task-2646' }],
1122
+ statusByBranch: { 'claude/task-2646': NONE, 'worktree-task-2646': PENDING },
1123
+ taskPrByTask: { 2646: { branch: 'worktree-task-2646', pr_number: 37, pr_state: 'open', merged: false } },
1124
+ mergeByBranch: { 'worktree-task-2646': { merged: true, merged_now: true, number: 37 } },
1125
+ });
1126
+ const r = await reconciler.runSweep(deps);
1127
+ assert.equal(r.merged, 1, 'the sweep merges the wave PR itself — the land no longer depends on a live ship.js poll');
1128
+ assert.equal(r.reconciled, 0, 'ships on a later sweep once deploy + proof confirm');
1129
+ });
1130
+
1131
+ test('1002645 a legacy laptop strand (no recorded tip) heals retroactively via the adopted PR reference', async () => {
1132
+ const deps = mkDeps({
1133
+ rows: [{ task_id: 2616, builder_id: 95, worktree_name: 'task-2616' }], // no published_head_sha
1134
+ statusByBranch: { 'claude/task-2616': NONE, 'worktree-task-2616': landedPrFor(2616) },
1135
+ taskPrByTask: { 2616: { branch: 'worktree-task-2616', pr_number: 36, pr_state: 'closed', merged: true } },
1136
+ });
1137
+ const r = await reconciler.runSweep(deps);
1138
+ assert.equal(r.reconciled, 1, 'the PRs-34/35/36 shape heals with no manual POST /ship');
1139
+ assert.deepEqual(deps._calls.ship, [{ taskId: 2616, builderId: 95 }]);
1140
+ });
1141
+
1142
+ test('1002645 the fallback does NOT fire when the derived branch already has a PR (dev-box path unchanged)', async () => {
1143
+ const deps = mkDeps({
1144
+ rows: [{ task_id: 900, builder_id: 1, worktree_name: 'wt-box' }],
1145
+ statusByBranch: { 'claude/wt-box': PENDING },
1146
+ taskPrByTask: { 900: new Error('search must not run') },
1147
+ });
1148
+ await reconciler.runSweep(deps);
1149
+ assert.equal(deps._calls.search.length, 0, 'a dev-box ship keeps the derived-branch path — no extra API call');
1150
+ });
1151
+
1152
+ test('1002645 a PR-search failure is swallowed — the sweep continues to the next task and retries next sweep', async () => {
1153
+ const deps = mkDeps({
1154
+ rows: [
1155
+ { task_id: 901, builder_id: 1, worktree_name: 'task-901' },
1156
+ { task_id: 902, builder_id: 1, worktree_name: 'wt-ok', published_head_sha: ON_MAIN },
1157
+ ],
1158
+ statusByBranch: { 'claude/task-901': NONE, 'claude/wt-ok': LANDED },
1159
+ taskPrByTask: { 901: new Error('GitHub 502') },
1160
+ ancestryBySha: { [ON_MAIN]: true },
1161
+ });
1162
+ const r = await reconciler.runSweep(deps);
1163
+ assert.equal(r.reconciled, 1, 'the healthy task still ships');
1164
+ assert.equal(r.errors, 0, 'a transient search miss is not an error');
1165
+ });
1166
+
1167
+ test('1002645 an unsafe adopted branch name is refused (the injection guard holds on the fallback)', async () => {
1168
+ const deps = mkDeps({
1169
+ rows: [{ task_id: 903, builder_id: 1, worktree_name: null, published_head_sha: null,
1170
+ confirmed_at: new Date().toISOString() }],
1171
+ taskPrByTask: { 903: { branch: '-rf', pr_number: 1, pr_state: 'open', merged: false } },
1172
+ });
1173
+ await reconciler.runSweep(deps);
1174
+ assert.equal(deps._calls.status.length, 0, 'a branch that fails isSafeBranch is never queried');
1175
+ assert.equal(deps._calls.merge.length, 0, 'nor merge-nudged');
1176
+ });
1177
+
1178
+ test('1002645 the reuse guard still holds through the fallback: adopted PR merged but tip NOT in main → NO ship', async () => {
1179
+ const deps = mkDeps({
1180
+ rows: [{ task_id: 904, builder_id: 1, worktree_name: 'task-904', published_head_sha: NOT_ON_MAIN,
1181
+ confirmed_at: new Date().toISOString() }],
1182
+ statusByBranch: { 'claude/task-904': NONE, 'worktree-task-904': LANDED_NO_WORKFLOW },
1183
+ taskPrByTask: { 904: { branch: 'worktree-task-904', pr_number: 40, pr_state: 'closed', merged: true } },
1184
+ ancestryBySha: { [NOT_ON_MAIN]: false },
1185
+ });
1186
+ const r = await reconciler.runSweep(deps);
1187
+ assert.equal(r.reconciled, 0, 'adopting the real branch must not weaken the task-1547 per-task proof');
1188
+ assert.equal(deps._calls.ship.length, 0);
1189
+ });
1190
+
1080
1191
  // Drain the microtask queue, then report + exit with the right code.
1081
1192
  setTimeout(() => {
1082
1193
  console.log(`\npublish reconciler (task 1134 + 1915) tests: ${passed} passed, ${failed} failed`);
@@ -607,6 +607,27 @@ test('1002606 landPendingLines: merged variant says deploy-only', () => {
607
607
  assert.match(unmerged, /GitHub will merge \+ deploy automatically/);
608
608
  });
609
609
 
610
+ // ---- task 1002645: the merged-but-proof-refused close-out (reconcile_blocked) ----
611
+ // GitHub can report merged+deployed while the SERVER refused the confirmed→shipped
612
+ // flip (the task-1547 land proof missed). pollServerPublish used to print "Prod is
613
+ // live" / the optimistic P-3 banner for that shape — a lie: without proof the
614
+ // auto-land can NEVER finish. The close-out must name the refusal and the remedy,
615
+ // and must never claim success or "NOT a failure".
616
+
617
+ test('1002645 reconcileBlockedLines: names the refusal reason + the re-drive command', () => {
618
+ const lines = ship.reconcileBlockedLines(2645, 'published tip abcd1234 not in main (branch-reuse/stale PR)').join('\n');
619
+ assert.match(lines, /did NOT flip|could not prove/i, 'says the flip was refused');
620
+ assert.match(lines, /published tip abcd1234 not in main/, 'carries the server\'s exact reason');
621
+ assert.match(lines, /node scripts\/gds\/ship\.js 2645/, 'the re-drive command names THIS task');
622
+ assert.match(lines, /needs_rebase/, 'names the flag that gates a stuck strand');
623
+ });
624
+
625
+ test('1002645 reconcileBlockedLines: never reads as success (no "NOT a failure", no "Prod is live")', () => {
626
+ const lines = ship.reconcileBlockedLines(1, 'x').join('\n');
627
+ assert.ok(!/NOT a failure/.test(lines), 'the optimistic reassurance must not appear on a blocked land');
628
+ assert.ok(!/Prod is live/.test(lines), 'nor the success line');
629
+ });
630
+
610
631
  console.log('');
611
632
  console.log(`ship_ci_deploy: ${passed} passed, ${failed} failed`);
612
633
  process.exit(failed === 0 ? 0 : 1);