@bongos/core 1.19.316 → 1.19.318

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.
Files changed (42) hide show
  1. package/.bongos-core.json +108 -33
  2. package/modules/economy/cost.js +84 -19
  3. package/modules/lifecycle/db-analytics.js +719 -0
  4. package/modules/lifecycle/db-claim-reads.js +165 -0
  5. package/modules/lifecycle/db-claims.js +934 -0
  6. package/modules/lifecycle/db-deps-criteria.js +441 -0
  7. package/modules/lifecycle/db-goals.js +763 -0
  8. package/modules/lifecycle/db-grade.js +233 -0
  9. package/modules/lifecycle/db-overrides.js +181 -0
  10. package/modules/lifecycle/db-rank-authz.js +446 -0
  11. package/modules/lifecycle/db-shared.js +115 -0
  12. package/modules/lifecycle/db-ship.js +994 -0
  13. package/modules/lifecycle/db-tasks.js +810 -0
  14. package/modules/lifecycle/db-versions.js +118 -0
  15. package/modules/lifecycle/db.js +167 -5564
  16. package/modules/lifecycle/publish-reconciler.js +7 -2
  17. package/package-lock.json +2 -2
  18. package/package.json +1 -1
  19. package/scripts/gds/coreB-carve-proof.sh +18 -9
  20. package/scripts/gds/fitness.js +34 -26
  21. package/scripts/gds/identity-strip.js +75 -0
  22. package/scripts/gds/provision.js +7 -3
  23. package/scripts/gds/run-unit-tests.js +8 -1
  24. package/scripts/gds/session-cost.js +16 -0
  25. package/scripts/gds/session-digest-build.js +261 -0
  26. package/scripts/gds/ship.js +7 -198
  27. package/src/module-api.js +5 -1
  28. package/tests/auto_satisfy_criteria.mjs +3 -2
  29. package/tests/dependencies_migration.mjs +3 -2
  30. package/tests/fitness.mjs +43 -0
  31. package/tests/goal_abuse_matrix.mjs +3 -2
  32. package/tests/goal_advisory.mjs +3 -2
  33. package/tests/government_claim_eligibility.mjs +3 -1
  34. package/tests/government_rank_sync.mjs +9 -2
  35. package/tests/helpers.mjs +19 -0
  36. package/tests/migration_allocation.mjs +3 -1
  37. package/tests/publish_reconciler.mjs +41 -1
  38. package/tests/rank_gate.mjs +3 -1
  39. package/tests/reward_gate.mjs +3 -1
  40. package/tests/session_cost.mjs +64 -0
  41. package/tests/session_cost_row_raise.mjs +139 -0
  42. package/tests/session_records.mjs +72 -0
@@ -0,0 +1,719 @@
1
+ // modules/lifecycle/db-analytics.js — estimation drift, grade aggregates, builder/ecosystem analytics, merge lease + peer-vote karma.
2
+ //
3
+ // Carved out of modules/lifecycle/db.js by task 1003204 (goal 1000079, the R03
4
+ // de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
5
+ // original db.js lines 4916-5598 unchanged. db.js remains the facade that
6
+ // re-exports every name, so every existing db call site is unchanged.
7
+ //
8
+ // THE BOUNDARY (ADR 0093 §1): this file reaches core ONLY through the published
9
+ // doorway ../../src/module-api.js — never a core internal, never a sibling module.
10
+
11
+ 'use strict';
12
+
13
+ const api = require('../../src/module-api');
14
+ const { pool, withTx } = api;
15
+ const { SMOKE_NOT_LIKE, gradePassCount, gradeQualityWhere, median } = require('./db-shared.js');
16
+ const { CREDITS_AWARDED_SUBQUERY } = require('./db-tasks.js');
17
+
18
+ // -------------------------------------------------------------------------
19
+ // Cost log + summary carved into modules/economy/cost.js (BV1.R77 / ADR 0093
20
+ // §§1-2): logCost (the one cost_log writer), reconcileSessionCosts,
21
+ // costSummary, and costCalibrationSummary left this monolith. routes/cost.js
22
+ // + the reconciler + the public dashboard resolve them via the `reward` kernel
23
+ // port. (The DAY_SERIES_CTE constant they shared moved with them.)
24
+ // -------------------------------------------------------------------------
25
+
26
+ // -------------------------------------------------------------------------
27
+ // Recent shipped tasks (for status page feed)
28
+ // -------------------------------------------------------------------------
29
+
30
+ // GDS #191: optional `days` (positive integer) restricts to rows shipped within
31
+ // the trailing N days. `null` / `undefined` / `0` means no date filter (all time).
32
+ // Limit is still applied either way so the status page never pulls an unbounded
33
+ // payload. Default keeps the historical 30-row "recent" feel for callers that
34
+ // don't pass options.
35
+
36
+ async function recentShippedTasks(limit = 30, { days = null } = {}) {
37
+ const params = [limit];
38
+ let whereClause = `t.status = 'shipped'
39
+ AND ${SMOKE_NOT_LIKE}`;
40
+ if (days && Number.isFinite(days) && days > 0) {
41
+ params.push(days);
42
+ whereClause += `\n AND t.shipped_at > now() - ($${params.length}::int * interval '1 day')`;
43
+ }
44
+ const { rows } = await pool.query(
45
+ `SELECT t.id, t.title, t.value_summary, t.version_id, t.status, t.shipped_at, t.credits_reward,
46
+ ${CREDITS_AWARDED_SUBQUERY} AS credits_awarded,
47
+ t.est_minutes, t.actual_minutes,
48
+ b.github_login, b.display_name
49
+ FROM tasks t
50
+ LEFT JOIN builders b ON b.id = t.shipped_by
51
+ WHERE ${whereClause}
52
+ ORDER BY t.shipped_at DESC NULLS LAST
53
+ LIMIT $1`,
54
+ params
55
+ );
56
+ return rows;
57
+ }
58
+
59
+ // Rolling median of actual/est ratio for a builder's last N shipped tasks.
60
+ // Returns null if there is insufficient data.
61
+
62
+ async function getEstVsActualRatio(builderId, limit = 10) {
63
+ const { rows } = await pool.query(
64
+ `SELECT actual_minutes::float / est_minutes AS ratio
65
+ FROM tasks
66
+ WHERE shipped_by = $1
67
+ AND status = 'shipped'
68
+ AND actual_minutes IS NOT NULL
69
+ AND est_minutes IS NOT NULL
70
+ AND est_minutes > 0
71
+ ORDER BY shipped_at DESC NULLS LAST
72
+ LIMIT $2`,
73
+ [builderId, limit]
74
+ );
75
+ if (rows.length === 0) return null;
76
+ const ratios = rows.map((r) => Number(r.ratio));
77
+ const med = median(ratios);
78
+ return { median: Math.round(med * 100) / 100, sample_size: ratios.length };
79
+ }
80
+
81
+ // Aggregated estimation drift for the public status page.
82
+ // Returns overall median ratio + per-version breakdown.
83
+
84
+ async function estimationDriftSummary() {
85
+ const { rows } = await pool.query(
86
+ `SELECT version_id,
87
+ actual_minutes::float / est_minutes AS ratio
88
+ FROM tasks
89
+ WHERE status = 'shipped'
90
+ AND actual_minutes IS NOT NULL
91
+ AND est_minutes IS NOT NULL
92
+ AND est_minutes > 0
93
+ AND ${SMOKE_NOT_LIKE}
94
+ ORDER BY shipped_at DESC NULLS LAST`
95
+ );
96
+
97
+ if (rows.length === 0) {
98
+ return { overall_median: null, sample_size: 0, by_version: [] };
99
+ }
100
+
101
+ const allRatios = rows.map((r) => Number(r.ratio));
102
+ const overallMedian = Math.round(median(allRatios) * 100) / 100;
103
+
104
+ const byVersion = new Map();
105
+ for (const r of rows) {
106
+ if (!byVersion.has(r.version_id)) byVersion.set(r.version_id, []);
107
+ byVersion.get(r.version_id).push(Number(r.ratio));
108
+ }
109
+
110
+ const versionBreakdown = Array.from(byVersion.entries()).map(([version_id, ratios]) => ({
111
+ version_id,
112
+ median_ratio: Math.round(median(ratios) * 100) / 100,
113
+ task_count: ratios.length,
114
+ }));
115
+
116
+ return {
117
+ overall_median: overallMedian,
118
+ sample_size: allRatios.length,
119
+ by_version: versionBreakdown,
120
+ };
121
+ }
122
+
123
+
124
+ async function recentShippedTasksByBuilder(builderId, limit = 10) {
125
+ const { rows } = await pool.query(
126
+ `SELECT t.id, t.title, t.value_summary, t.version_id, t.status, t.shipped_at, t.credits_reward,
127
+ ${CREDITS_AWARDED_SUBQUERY} AS credits_awarded
128
+ FROM tasks t
129
+ WHERE t.shipped_by = $1
130
+ AND t.status = 'shipped'
131
+ AND t.title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'
132
+ ORDER BY t.shipped_at DESC NULLS LAST
133
+ LIMIT $2`,
134
+ [builderId, limit]
135
+ );
136
+ return rows;
137
+ }
138
+
139
+ // -------------------------------------------------------------------------
140
+ // Achievement getters (listAchievements / getBuilderAchievements /
141
+ // getLeaderboardWithAchievements / getLockedAchievementsProgress /
142
+ // renderProgressText) carved into modules/economy/achievements.js (BV1.R77 /
143
+ // ADR 0093 §§1-2). me.js + public.js resolve them via the `reward` kernel port.
144
+ // -------------------------------------------------------------------------
145
+
146
+ // -------------------------------------------------------------------------
147
+ // Karma feed (V3.R28 / #248)
148
+ // -------------------------------------------------------------------------
149
+
150
+ // Last N karma_log entries for this builder, newest first. Powers the
151
+ // proposer's karma feed — the "Done when: visible in the proposer's karma
152
+ // feed" criterion for V3.R28. When R26 (#246) ships the profile-page karma
153
+ // trend UI, the page reads from this helper. Lightweight join to builders
154
+ // so the UI can render "granted by @<login>" without a second query.
155
+ //
156
+ // Append-only by construction (karma_log has no DELETE/UPDATE paths), so
157
+ // this is safe to call freely.
158
+
159
+ async function recentGradesForBuilder(builderId, limit = 5) {
160
+ // panel_outcome rides along so consumers can LABEL an outage row ("not
161
+ // graded") instead of rendering its placeholder 0.0 as a fail (task 1002652).
162
+ // A list read stays unfiltered — honesty here means labeling, not hiding.
163
+ const { rows } = await pool.query(
164
+ `SELECT g.task_id, g.score, g.passed, g.threshold, g.graded_at,
165
+ g.grader_kind, g.grader_version, g.rubric_scores,
166
+ g.signals->>'panel_outcome' AS panel_outcome,
167
+ COALESCE(g.signals->'issues', '[]'::jsonb) AS issues,
168
+ COALESCE((g.signals->>'confidence')::numeric, NULL) AS confidence,
169
+ t.title, t.kind, t.discipline
170
+ FROM task_grades g
171
+ JOIN tasks t ON t.id = g.task_id
172
+ WHERE t.shipped_by = $1
173
+ ORDER BY g.graded_at DESC
174
+ LIMIT $2`,
175
+ [builderId, limit]
176
+ );
177
+ return rows;
178
+ }
179
+
180
+ // Rolling 7d avg grade across ALL builders, plus pass-rate. Powers the
181
+ // public status tile + leaderboard hover.
182
+
183
+ async function rollingAvgGrade(days = 7) {
184
+ // Quality stats aggregate ONLY quality-graded rows; outage / null-axis rows
185
+ // are counted apart as n_unavailable (task 1002652 — 21 outage rows once
186
+ // read as a month of pass_rate 0.000).
187
+ const q = gradeQualityWhere();
188
+ const { rows } = await pool.query(
189
+ `SELECT
190
+ COUNT(*) FILTER (WHERE ${q})::int AS n,
191
+ COALESCE(AVG(score) FILTER (WHERE ${q}), 0)::numeric(3,1) AS avg_score,
192
+ ${gradePassCount('passed', { where: q })} AS n_passed,
193
+ COALESCE(MIN(score) FILTER (WHERE ${q}), 0)::numeric(3,1) AS min_score,
194
+ COALESCE(MAX(score) FILTER (WHERE ${q}), 0)::numeric(3,1) AS max_score,
195
+ COUNT(*) FILTER (WHERE NOT ${q})::int AS n_unavailable
196
+ FROM task_grades
197
+ WHERE graded_at >= now() - ($1 || ' days')::interval`,
198
+ [String(days)]
199
+ );
200
+ return rows[0] || { n: 0, avg_score: 0, n_passed: 0, min_score: 0, max_score: 0, n_unavailable: 0 };
201
+ }
202
+
203
+ // Per-builder rolling avg, last `days` days. Powers the /builders profile.
204
+
205
+ async function gradeByBuilder(days = 30) {
206
+ // pass_rate is NULL (not 0.000) for a builder whose window holds only
207
+ // outage / null-axis rows — "no graded work", never a fabricated fail-streak
208
+ // (task 1002652). fmtPct-style consumers already render null as '—'.
209
+ const q = gradeQualityWhere('g');
210
+ const { rows } = await pool.query(
211
+ `SELECT
212
+ t.shipped_by AS builder_id,
213
+ b.github_login,
214
+ b.display_name,
215
+ b.avatar_url,
216
+ COUNT(*) FILTER (WHERE ${q})::int AS n,
217
+ COALESCE(AVG(g.score) FILTER (WHERE ${q}), 0)::numeric(3,1) AS avg_score,
218
+ ${gradePassCount('g.passed', { where: q })} AS n_passed,
219
+ ROUND(AVG(CASE WHEN g.passed THEN 1 ELSE 0 END) FILTER (WHERE ${q}), 3) AS pass_rate,
220
+ COUNT(*) FILTER (WHERE NOT ${q})::int AS n_unavailable,
221
+ MAX(g.graded_at) AS last_graded_at
222
+ FROM task_grades g
223
+ JOIN tasks t ON t.id = g.task_id
224
+ LEFT JOIN builders b ON b.id = t.shipped_by
225
+ WHERE g.graded_at >= now() - ($1 || ' days')::interval
226
+ AND t.shipped_by IS NOT NULL
227
+ GROUP BY t.shipped_by, b.github_login, b.display_name, b.avatar_url
228
+ ORDER BY n DESC, avg_score DESC`,
229
+ [String(days)]
230
+ );
231
+ return rows;
232
+ }
233
+
234
+ // Public grade-trend timeline — array of { day, n, avg_score }. Powers a
235
+ // chart on status.amazonprimea.com.
236
+
237
+ async function gradeTrendByDay(days = 30) {
238
+ // A day whose rows are all outages keeps its row (n=0, avg_score NULL,
239
+ // n_unavailable>0) so a chart can show "grader down", never a 0.0-score day
240
+ // (task 1002652 — the 2026-07-12..19 zero-score week was five outage days).
241
+ const q = gradeQualityWhere();
242
+ const { rows } = await pool.query(
243
+ `SELECT
244
+ to_char(date_trunc('day', graded_at), 'YYYY-MM-DD') AS day,
245
+ COUNT(*) FILTER (WHERE ${q})::int AS n,
246
+ AVG(score) FILTER (WHERE ${q})::numeric(3,1) AS avg_score,
247
+ ${gradePassCount('passed', { where: q })} AS n_passed,
248
+ COUNT(*) FILTER (WHERE NOT ${q})::int AS n_unavailable
249
+ FROM task_grades
250
+ WHERE graded_at >= now() - ($1 || ' days')::interval
251
+ GROUP BY date_trunc('day', graded_at)
252
+ ORDER BY day ASC`,
253
+ [String(days)]
254
+ );
255
+ return rows;
256
+ }
257
+
258
+ // -------------------------------------------------------------------------
259
+ // Builder analytics — the "AI dev curve" (V3.R72 / #291, criterion C5).
260
+ //
261
+ // Per-builder historical time-series powering the /builders profile analytics
262
+ // panel and (aggregated, individuals stripped) the public /status ecosystem
263
+ // tile. Everything derives from existing columns — tasks.shipped_at /
264
+ // actual_minutes / est_minutes / kind, cost_log.builder_id (migration 046),
265
+ // claims.outcome, and task_grades — so no migration is needed. Smoke tasks
266
+ // (`__smoke__*`) are excluded everywhere, mirroring the other status feeds.
267
+ // One round trip per series, fanned out with Promise.all.
268
+ // (SMOKE_NOT_LIKE is now declared once at module top — task 1087, C3.)
269
+ // -------------------------------------------------------------------------
270
+
271
+
272
+ async function builderAnalytics(builderId, days = 90) {
273
+ const d = String(days);
274
+ const [shipRate, costTrend, durationTrend, gradeTrend, topKinds, headline, claimRollup] =
275
+ await Promise.all([
276
+ // Ships per day — the core "dev curve".
277
+ pool.query(
278
+ `SELECT to_char(date_trunc('day', shipped_at), 'YYYY-MM-DD') AS day,
279
+ COUNT(*)::int AS n
280
+ FROM tasks
281
+ WHERE shipped_by = $1 AND status = 'shipped'
282
+ AND shipped_at >= now() - ($2 || ' days')::interval
283
+ AND ${SMOKE_NOT_LIKE}
284
+ GROUP BY 1 ORDER BY 1 ASC`,
285
+ [builderId, d]
286
+ ),
287
+ // Cost per day + tasks touched that day → cost-per-task in JS.
288
+ pool.query(
289
+ `SELECT to_char(date_trunc('day', recorded_at), 'YYYY-MM-DD') AS day,
290
+ SUM(amount_usd)::numeric(12,2) AS total_usd,
291
+ COUNT(DISTINCT task_id)::int AS n_tasks
292
+ FROM cost_log
293
+ WHERE builder_id = $1
294
+ AND recorded_at >= now() - ($2 || ' days')::interval
295
+ GROUP BY 1 ORDER BY 1 ASC`,
296
+ [builderId, d]
297
+ ),
298
+ // Avg actual vs estimated minutes per day (the "getting faster" curve).
299
+ pool.query(
300
+ `SELECT to_char(date_trunc('day', shipped_at), 'YYYY-MM-DD') AS day,
301
+ ROUND(AVG(actual_minutes))::int AS avg_actual,
302
+ ROUND(AVG(est_minutes))::int AS avg_est
303
+ FROM tasks
304
+ WHERE shipped_by = $1 AND status = 'shipped'
305
+ AND actual_minutes IS NOT NULL AND est_minutes IS NOT NULL
306
+ AND shipped_at >= now() - ($2 || ' days')::interval
307
+ AND ${SMOKE_NOT_LIKE}
308
+ GROUP BY 1 ORDER BY 1 ASC`,
309
+ [builderId, d]
310
+ ),
311
+ // Grade pass-rate per day (quality holding up as throughput rises?).
312
+ // Quality-graded rows only — outage / null-axis rows would read as
313
+ // fail-days (task 1002652); headline.grade_pass_rate derives from these
314
+ // rows so it inherits the same honesty.
315
+ pool.query(
316
+ `SELECT to_char(date_trunc('day', g.graded_at), 'YYYY-MM-DD') AS day,
317
+ COUNT(*) FILTER (WHERE ${gradeQualityWhere('g')})::int AS n,
318
+ ${gradePassCount('g.passed', { where: gradeQualityWhere('g') })} AS n_passed,
319
+ AVG(g.score) FILTER (WHERE ${gradeQualityWhere('g')})::numeric(3,1) AS avg_score
320
+ FROM task_grades g
321
+ JOIN tasks t ON t.id = g.task_id
322
+ WHERE t.shipped_by = $1
323
+ AND g.graded_at >= now() - ($2 || ' days')::interval
324
+ GROUP BY 1 ORDER BY 1 ASC`,
325
+ [builderId, d]
326
+ ),
327
+ // Top kinds shipped (all-time — a "what do I build" fingerprint).
328
+ pool.query(
329
+ `SELECT COALESCE(kind, 'unspecified') AS kind, COUNT(*)::int AS n
330
+ FROM tasks
331
+ WHERE shipped_by = $1 AND status = 'shipped'
332
+ AND ${SMOKE_NOT_LIKE}
333
+ GROUP BY 1 ORDER BY n DESC, kind ASC`,
334
+ [builderId]
335
+ ),
336
+ // Window headline numbers.
337
+ pool.query(
338
+ `SELECT COUNT(*) FILTER (WHERE status = 'shipped')::int AS total_shipped,
339
+ COALESCE(SUM(actual_minutes) FILTER (
340
+ WHERE status = 'shipped' AND actual_minutes IS NOT NULL), 0)::int AS total_minutes,
341
+ COALESCE(ROUND(AVG(actual_minutes) FILTER (
342
+ WHERE status = 'shipped' AND actual_minutes IS NOT NULL)), 0)::int AS avg_duration_min
343
+ FROM tasks
344
+ WHERE shipped_by = $1
345
+ AND ${SMOKE_NOT_LIKE}
346
+ AND (shipped_at IS NULL OR shipped_at >= now() - ($2 || ' days')::interval)`,
347
+ [builderId, d]
348
+ ),
349
+ // Confirmation rate — share of resolved claims that ended in a ship
350
+ // (vs. abandoned / expired / partial). Distinct from the grade pass-rate.
351
+ pool.query(
352
+ `SELECT COUNT(*)::int AS resolved_claims,
353
+ SUM(CASE WHEN outcome = 'shipped' THEN 1 ELSE 0 END)::int AS shipped_claims
354
+ FROM claims
355
+ WHERE builder_id = $1 AND outcome IS NOT NULL
356
+ AND claimed_at >= now() - ($2 || ' days')::interval`,
357
+ [builderId, d]
358
+ ),
359
+ ]);
360
+
361
+ // task 1503: the "Cost / work ($)" chart plots per-day cost-per-work. The old
362
+ // per_task = day's cost / COUNT(DISTINCT cost_log.task_id) flat-lined at 0 —
363
+ // most cost rows carry a NULL task_id (session-token / infra / api spend), so
364
+ // the DISTINCT count was 0 for those days and per_task fell to 0; on the rare
365
+ // task-attributed day it spiked wildly. Cost (recorded_at) and works
366
+ // (shipped_at) don't align by day, so a same-day ratio can't be meaningful.
367
+ // Instead expose the CUMULATIVE cost-per-work as of each day — running total
368
+ // spend / running works shipped — which is always defined once there's a ship
369
+ // and converges to headline.cost_per_task.
370
+ const shipsByDay = new Map(shipRate.rows.map((r) => [r.day, Number(r.n) || 0]));
371
+ const costByDay = new Map(costTrend.rows.map((r) => [r.day, Number(r.total_usd) || 0]));
372
+ const curveDays = Array.from(new Set([
373
+ ...costTrend.rows.map((r) => r.day),
374
+ ...shipRate.rows.map((r) => r.day),
375
+ ])).sort();
376
+ let cumCost = 0;
377
+ let cumShips = 0;
378
+ const perWorkByDay = new Map();
379
+ for (const day of curveDays) {
380
+ cumCost += costByDay.get(day) || 0;
381
+ cumShips += shipsByDay.get(day) || 0;
382
+ perWorkByDay.set(day, cumShips > 0 ? Math.round((cumCost / cumShips) * 100) / 100 : 0);
383
+ }
384
+ const cost_trend = costTrend.rows.map((r) => ({
385
+ day: r.day,
386
+ total_usd: Number(r.total_usd),
387
+ n_tasks: r.n_tasks,
388
+ // Cumulative cost-per-work as of this day (see note above). Converges to
389
+ // headline.cost_per_task; never the old all-zero flat line.
390
+ per_work: perWorkByDay.get(r.day) ?? 0,
391
+ }));
392
+ const totalCost = cost_trend.reduce((s, r) => s + r.total_usd, 0);
393
+
394
+ const h = headline.rows[0] || { total_shipped: 0, total_minutes: 0, avg_duration_min: 0 };
395
+ const c = claimRollup.rows[0] || { resolved_claims: 0, shipped_claims: 0 };
396
+ const grades = gradeTrend.rows;
397
+ const gradedN = grades.reduce((s, r) => s + r.n, 0);
398
+ const gradedPassed = grades.reduce((s, r) => s + r.n_passed, 0);
399
+
400
+ return {
401
+ window_days: days,
402
+ ship_rate: shipRate.rows, // [{ day, n }]
403
+ cost_trend, // [{ day, total_usd, n_tasks, per_work }] — per_work is cumulative (task 1503)
404
+ duration_trend: durationTrend.rows, // [{ day, avg_actual, avg_est }]
405
+ grade_trend: grades, // [{ day, n, n_passed, avg_score }]
406
+ top_kinds: topKinds.rows, // [{ kind, n }]
407
+ headline: {
408
+ total_shipped: h.total_shipped,
409
+ total_cost_usd: Math.round(totalCost * 100) / 100,
410
+ cost_per_task: h.total_shipped > 0 ? Math.round((totalCost / h.total_shipped) * 100) / 100 : 0,
411
+ avg_duration_min: h.avg_duration_min,
412
+ confirmation_rate: c.resolved_claims > 0 ? Math.round((c.shipped_claims / c.resolved_claims) * 100) / 100 : null,
413
+ resolved_claims: c.resolved_claims,
414
+ grade_pass_rate: gradedN > 0 ? Math.round((gradedPassed / gradedN) * 100) / 100 : null,
415
+ graded_n: gradedN,
416
+ },
417
+ };
418
+ }
419
+
420
+ // Ecosystem-wide aggregate — the SAME shape of series as builderAnalytics but
421
+ // summed across every builder, with NO per-builder identity. This is the only
422
+ // analytics surface the public /status page is allowed to render (individuals
423
+ // stay behind the authenticated /analytics/builder/:id route). Grade + cost
424
+ // daily series already have public feeds (/public/grades, /public/cost-summary),
425
+ // so this adds just the ship-rate, duration, top-kinds, and headline aggregates.
426
+
427
+ async function ecosystemAnalytics(days = 90) {
428
+ const d = String(days);
429
+ const [shipRate, durationTrend, topKinds, headline] = await Promise.all([
430
+ pool.query(
431
+ `SELECT to_char(date_trunc('day', shipped_at), 'YYYY-MM-DD') AS day,
432
+ COUNT(*)::int AS n
433
+ FROM tasks
434
+ WHERE status = 'shipped' AND shipped_by IS NOT NULL
435
+ AND shipped_at >= now() - ($1 || ' days')::interval
436
+ AND ${SMOKE_NOT_LIKE}
437
+ GROUP BY 1 ORDER BY 1 ASC`,
438
+ [d]
439
+ ),
440
+ pool.query(
441
+ `SELECT to_char(date_trunc('day', shipped_at), 'YYYY-MM-DD') AS day,
442
+ ROUND(AVG(actual_minutes))::int AS avg_actual,
443
+ ROUND(AVG(est_minutes))::int AS avg_est
444
+ FROM tasks
445
+ WHERE status = 'shipped'
446
+ AND actual_minutes IS NOT NULL AND est_minutes IS NOT NULL
447
+ AND shipped_at >= now() - ($1 || ' days')::interval
448
+ AND ${SMOKE_NOT_LIKE}
449
+ GROUP BY 1 ORDER BY 1 ASC`,
450
+ [d]
451
+ ),
452
+ pool.query(
453
+ `SELECT COALESCE(kind, 'unspecified') AS kind, COUNT(*)::int AS n
454
+ FROM tasks
455
+ WHERE status = 'shipped' AND shipped_by IS NOT NULL
456
+ AND shipped_at >= now() - ($1 || ' days')::interval
457
+ AND ${SMOKE_NOT_LIKE}
458
+ GROUP BY 1 ORDER BY n DESC, kind ASC`,
459
+ [d]
460
+ ),
461
+ pool.query(
462
+ `SELECT COUNT(*)::int AS total_shipped,
463
+ COUNT(DISTINCT shipped_by)::int AS active_builders,
464
+ COALESCE(ROUND(AVG(actual_minutes) FILTER (
465
+ WHERE actual_minutes IS NOT NULL)), 0)::int AS avg_duration_min
466
+ FROM tasks
467
+ WHERE status = 'shipped' AND shipped_by IS NOT NULL
468
+ AND shipped_at >= now() - ($1 || ' days')::interval
469
+ AND ${SMOKE_NOT_LIKE}`,
470
+ [d]
471
+ ),
472
+ ]);
473
+
474
+ const h = headline.rows[0] || { total_shipped: 0, active_builders: 0, avg_duration_min: 0 };
475
+ return {
476
+ window_days: days,
477
+ ship_rate: shipRate.rows,
478
+ duration_trend: durationTrend.rows,
479
+ top_kinds: topKinds.rows,
480
+ headline: {
481
+ total_shipped: h.total_shipped,
482
+ active_builders: h.active_builders,
483
+ avg_duration_min: h.avg_duration_min,
484
+ },
485
+ };
486
+ }
487
+
488
+ // -------------------------------------------------------------------------
489
+ // audit_log (V3.R35 / #256) — append-only stream of authenticated writes.
490
+ //
491
+ // The writing middleware (src/bongos/middleware/audit.js) calls insertAuditLog
492
+ // AFTER the response has been sent, wrapped in try/catch — so the request
493
+ // path never blocks on this insert and a DB failure here can never break a
494
+ // real user request. The reader is the archon-only GET /api/gds/audit-log
495
+ // endpoint backed by listAuditLog below (paginated, newest-first).
496
+ // -------------------------------------------------------------------------
497
+
498
+ // insertAuditLog — the append-only audit write — moved to ./db-kernel (BV1.R69 /
499
+ // ADR 0091 §2: audit is a kernel primitive). Imported + re-exported at the top
500
+ // of this file; the writing middleware + the listAuditLog reader below unchanged.
501
+
502
+ // listAuditLog — paginated, newest-first. Clamps limit to 1..500 and offset to
503
+ // >= 0 at the route layer; this helper trusts its inputs.
504
+ //
505
+ // Optional filters:
506
+ // builderId — only entries for that builder
507
+ // method — one of POST/PATCH/PUT/DELETE
508
+ // surface — originating surface (e.g. 'discord_bot'); the literal string
509
+ // 'none' selects direct human writes (surface IS NULL). This is
510
+ // what makes the documented forensic isolation of bot-origin
511
+ // writes actually queryable through the API, not just via psql
512
+ // (ADR 0033 §1 / F4 finding).
513
+
514
+ async function logGradeAttempt({ taskId, builderId, passed, score }, queryFn) {
515
+ const q = queryFn || ((text, params) => pool.query(text, params));
516
+ try {
517
+ await q(
518
+ `INSERT INTO grade_attempts (task_id, builder_id, passed, score) VALUES ($1, $2, $3, $4)`,
519
+ [
520
+ Number(taskId),
521
+ builderId == null ? null : Number(builderId),
522
+ passed === true,
523
+ score == null ? null : Number(score),
524
+ ]
525
+ );
526
+ return true;
527
+ } catch (err) {
528
+ console.error('[gds] logGradeAttempt (non-blocking):', err && err.message ? err.message : err);
529
+ return false;
530
+ }
531
+ }
532
+
533
+ // countGradeAttempts — how many times a task has been graded (re-grade thrash).
534
+
535
+ async function countGradeAttempts(taskId, queryFn) {
536
+ const q = queryFn || ((text, params) => pool.query(text, params));
537
+ const { rows } = await q(`SELECT COUNT(*)::int AS n FROM grade_attempts WHERE task_id = $1`, [Number(taskId)]);
538
+ return rows[0] ? rows[0].n : 0;
539
+ }
540
+
541
+ // gradeAttemptStats — the grade_attempts READER (task 1002661). The table has
542
+ // been written on every grade since 6D.6 and read by nothing, leaving
543
+ // remediation rate structurally unmeasurable. Windowed per-task rollup:
544
+ // remediation_rate = of the tasks that FAILED at least one attempt, the share
545
+ // whose latest attempt passed (null when no task failed — no denominator).
546
+ // waived_zero_issue_passes counts the ADR 0164 implausible_pass_waived audit
547
+ // markers in the same window, so the views can tell "waived on panel
548
+ // evidence" from "never evaluated" at a glance.
549
+ async function gradeAttemptStats(days = 7, queryFn) {
550
+ const q = queryFn || ((text, params) => pool.query(text, params));
551
+ const d = Number.isFinite(Number(days)) && Number(days) > 0 ? Math.min(Number(days), 90) : 7;
552
+ const [{ rows }, waived] = await Promise.all([q(
553
+ `SELECT
554
+ COALESCE(SUM(n), 0)::int AS total_attempts,
555
+ COUNT(*)::int AS graded_tasks,
556
+ COUNT(*) FILTER (WHERE n > 1)::int AS retried_tasks,
557
+ COUNT(*) FILTER (WHERE had_fail)::int AS tasks_with_fail,
558
+ COUNT(*) FILTER (WHERE had_fail AND last_passed)::int AS remediated_tasks
559
+ FROM (
560
+ SELECT task_id,
561
+ COUNT(*) AS n,
562
+ BOOL_OR(NOT passed) AS had_fail,
563
+ (ARRAY_AGG(passed ORDER BY attempted_at DESC))[1] AS last_passed
564
+ FROM grade_attempts
565
+ WHERE attempted_at > now() - ($1 * interval '1 day')
566
+ GROUP BY task_id
567
+ ) t`,
568
+ [d]
569
+ ), q(
570
+ `SELECT COUNT(*)::int AS n
571
+ FROM task_grades
572
+ WHERE graded_at > now() - ($1 * interval '1 day')
573
+ AND signals->'server_guard'->>'implausible_pass_waived' IS NOT NULL`,
574
+ [d]
575
+ )]);
576
+ const s = rows[0] || {};
577
+ const tasksWithFail = Number(s.tasks_with_fail) || 0;
578
+ const remediated = Number(s.remediated_tasks) || 0;
579
+ return {
580
+ window_days: d,
581
+ total_attempts: Number(s.total_attempts) || 0,
582
+ graded_tasks: Number(s.graded_tasks) || 0,
583
+ retried_tasks: Number(s.retried_tasks) || 0,
584
+ tasks_with_fail: tasksWithFail,
585
+ remediated_tasks: remediated,
586
+ remediation_rate: tasksWithFail > 0 ? Math.round((remediated / tasksWithFail) * 100) / 100 : null,
587
+ waived_zero_issue_passes: (waived.rows[0] && Number(waived.rows[0].n)) || 0,
588
+ };
589
+ }
590
+
591
+ // ---- security_docs: Archon-only sensitive security documents (task 991) -----
592
+ //
593
+ // The DATED security engagement reports (exploit detail) live in the DB, NOT in
594
+ // the git clone (threat-model §6.3 / ADR 0020). The route layer gates every read
595
+ // to requireRank('archon'); these helpers are pure parameterized SQL.
596
+
597
+ // Upsert a doc by slug — insert if new, update title/content (and bump
598
+ // updated_at) if the slug already exists. Returns the full row.
599
+
600
+ async function acquireMergeLease({ key = 'main', holder, ttlSeconds = 600 } = {}) {
601
+ if (!holder) throw new Error('acquireMergeLease: holder is required');
602
+ const { rows } = await pool.query(
603
+ `INSERT INTO merge_lease (key, holder, acquired_at, expires_at)
604
+ VALUES ($1, $2, now(), now() + make_interval(secs => $3))
605
+ ON CONFLICT (key) DO UPDATE
606
+ SET holder = EXCLUDED.holder, acquired_at = now(), expires_at = EXCLUDED.expires_at
607
+ WHERE merge_lease.expires_at <= now() OR merge_lease.holder = EXCLUDED.holder
608
+ RETURNING holder, expires_at`,
609
+ [key, holder, ttlSeconds]
610
+ );
611
+ if (rows.length) return { acquired: true, holder, expires_at: rows[0].expires_at };
612
+ const cur = await pool.query('SELECT holder, expires_at FROM merge_lease WHERE key = $1', [key]);
613
+ return { acquired: false, holder: cur.rows[0]?.holder ?? null, expires_at: cur.rows[0]?.expires_at ?? null };
614
+ }
615
+
616
+ // Release the lease IFF still held by this holder (defensive against releasing a lease that was
617
+ // already reclaimed + re-acquired by someone else after a TTL expiry). Returns { released }.
618
+
619
+ async function releaseMergeLease({ key = 'main', holder } = {}) {
620
+ if (!holder) throw new Error('releaseMergeLease: holder is required');
621
+ const { rowCount } = await pool.query(
622
+ 'DELETE FROM merge_lease WHERE key = $1 AND holder = $2',
623
+ [key, holder]
624
+ );
625
+ return { released: rowCount > 0 };
626
+ }
627
+
628
+ // Current lease holder + expiry (null if free). Read-only; safe on the request path.
629
+
630
+ async function mergeLeaseStatus({ key = 'main' } = {}) {
631
+ const { rows } = await pool.query(
632
+ `SELECT key, holder, acquired_at, expires_at, (expires_at <= now()) AS expired
633
+ FROM merge_lease WHERE key = $1`,
634
+ [key]
635
+ );
636
+ return rows[0] ?? null;
637
+ }
638
+
639
+ // Peer-vote → karma fold-in (V3.R25 / #245). The exact transaction that
640
+ // POST /tasks/peer-votes/tally runs, extracted so the route AND the
641
+ // server-internal weekly timer (the carved autonomy module's poller,
642
+ // modules/autonomy/pollers/routine-timers.js — it reaches this via the doorway
643
+ // `api.tallyPeerVotes`, BV1.R83 / ADR 0078 amendment) share ONE code path and
644
+ // can never diverge on tally semantics. Idempotent:
645
+ // only folds matured (>= lag days), not-yet-tallied votes, then stamps them
646
+ // tallied_at = now() — a re-run within the week finds nothing new. Writes
647
+ // karma_log with granted_by = NULL (a system fold-in has no requester); the
648
+ // route's Archon gate is an HTTP-auth concern, not a property of this SQL. The
649
+ // `FOR UPDATE OF pv` row lock is the real multi-instance guard: two processes
650
+ // (e.g. the timer + a manual run, or a second instance on the same DB) can't
651
+ // double-fold the same votes — the loser blocks, then sees tallied_at set.
652
+
653
+ const PEER_VOTE_KARMA_LAG_DAYS = 7;
654
+
655
+
656
+ async function tallyPeerVotes() {
657
+ return withTx(async (client) => {
658
+ const { rows: matured } = await client.query(
659
+ `SELECT pv.id, t.shipped_by AS builder_id, pv.vote
660
+ FROM peer_votes pv
661
+ JOIN tasks t ON t.id = pv.target_task_id
662
+ WHERE pv.tallied_at IS NULL
663
+ AND pv.recorded_at <= now() - ($1::int * interval '1 day')
664
+ AND t.shipped_by IS NOT NULL
665
+ FOR UPDATE OF pv`,
666
+ [PEER_VOTE_KARMA_LAG_DAYS]
667
+ );
668
+ const netByBuilder = new Map();
669
+ const voteIds = [];
670
+ for (const r of matured) {
671
+ voteIds.push(r.id);
672
+ netByBuilder.set(r.builder_id, (netByBuilder.get(r.builder_id) || 0) + r.vote);
673
+ }
674
+ const karmaRows = [];
675
+ for (const [builderId, net] of netByBuilder) {
676
+ if (net === 0) continue; // no net signal → no karma row, but still mark tallied
677
+ await client.query(
678
+ `INSERT INTO karma_log (builder_id, delta, reason, granted_by)
679
+ VALUES ($1, $2, 'peer_vote_weekly', NULL)`,
680
+ [builderId, net]
681
+ );
682
+ karmaRows.push({ builder_id: builderId, delta: net });
683
+ }
684
+ if (voteIds.length) {
685
+ await client.query(
686
+ `UPDATE peer_votes SET tallied_at = now() WHERE id = ANY($1::bigint[])`,
687
+ [voteIds]
688
+ );
689
+ }
690
+ return {
691
+ ok: true,
692
+ votes_tallied: voteIds.length,
693
+ builders_credited: karmaRows.length,
694
+ karma: karmaRows,
695
+ lag_days: PEER_VOTE_KARMA_LAG_DAYS,
696
+ };
697
+ });
698
+ }
699
+
700
+ module.exports = {
701
+ PEER_VOTE_KARMA_LAG_DAYS,
702
+ acquireMergeLease,
703
+ builderAnalytics,
704
+ countGradeAttempts,
705
+ ecosystemAnalytics,
706
+ estimationDriftSummary,
707
+ getEstVsActualRatio,
708
+ gradeAttemptStats,
709
+ gradeByBuilder,
710
+ gradeTrendByDay,
711
+ logGradeAttempt,
712
+ mergeLeaseStatus,
713
+ recentGradesForBuilder,
714
+ recentShippedTasks,
715
+ recentShippedTasksByBuilder,
716
+ releaseMergeLease,
717
+ rollingAvgGrade,
718
+ tallyPeerVotes,
719
+ };