@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
@@ -386,205 +386,14 @@ const {
386
386
  redactHighEntropy, collectEnvSecrets,
387
387
  } = require('../../modules/security/secret-scrub');
388
388
 
389
- // snippetOf — short, single-line, scrubbed excerpt for footgun context.
390
- function snippetOf(text, extraSecrets, max = 160) {
391
- if (typeof text !== 'string' || text.length === 0) return undefined;
392
- // Drop NULs first: Postgres rejects a literal NUL in jsonb, so ONE of them anywhere in
393
- // ONE snippet made the whole digest unstorable — POST /sessions answered 500 and
394
- // the session never paid. UTF-16LE tool output (`wsl --list` is the observed
395
- // case) is full of them, and \s below does NOT match NUL, so it must go here.
396
- const flat = text.replace(/\0/g, '').replace(/\s+/g, ' ').trim();
397
- if (!flat) return undefined;
398
- const cut = flat.length > max ? flat.slice(0, max) : flat;
399
- const scrubbed = scrubSecrets(cut, extraSecrets);
400
- return scrubbed || undefined;
401
- }
389
+ // snippetOf + buildSessionDigest moved to ./session-digest-build (task 1003217,
390
+ // serving the de-monolith goal of task 1003203) — pure, separately tested, and
391
+ // shared with session-digest.js. Re-exported below so this file's public surface
392
+ // and tests/session_records.mjs are unchanged.
393
+ const {
394
+ snippetOf, buildSessionDigest, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL,
395
+ } = require('./session-digest-build');
402
396
 
403
- // buildSessionDigest — turn parsed Claude Code transcript entries into
404
- // { startedAt, endedAt, durationSeconds, totalTokens, metrics, detail }.
405
- // Pure (no I/O) + exported for unit tests. `detail` is per-turn metadata + short
406
- // scrubbed snippets, capped at maxTurns (head+tail kept when over).
407
- // Harness guardrail retries (task 959) — an is_error tool_result that is NOT real
408
- // friction but the agent harness rejecting a tool call: an Edit/Write before the
409
- // file was Read ("File has not been read yet"), or a foreground sleep ("Blocked:
410
- // sleep"). These were counted in tool_error_count, inflating it and skewing the
411
- // BFG's order=tool_errors ranking (top sessions ranked on benign retries). They
412
- // now go into a separate harness_retry_count so tool_error_count stays NET — and
413
- // because the sessions/search ordering reads metrics->>'tool_error_count'
414
- // (src/bongos/db.js), that ordering automatically uses the net figure with no route
415
- // change. (ADR 0027 lineage — the 6D session evaluator.)
416
- const HARNESS_RETRY_RE =
417
- /File has not been read yet|Read it first before (?:writing|editing)|Blocked:\s*sleep|sleep is blocked in (?:the )?foreground/i;
418
-
419
- // task 1676 — the family bucket unlabeled token-bearing turns are folded into so
420
- // the cost-plus reward (ADR 0054) prices the real spend instead of rounding to $0.
421
- // A concrete, priceable model id (llm-pricing.js maps it to the opus family by
422
- // substring) — NOT a fabricated dollar figure; it carries the REAL token counts
423
- // ship.js already parsed, priced server-side like any other bucket.
424
- const UNATTRIBUTED_MODEL = 'claude-opus-4-8';
425
-
426
- function buildSessionDigest(entries, opts = {}) {
427
- const extra = opts.extraSecrets || [];
428
- const MAX_TURNS = opts.maxTurns || 4000;
429
- const m = {
430
- assistant_turns: 0,
431
- user_turns: 0,
432
- tool_use_count: 0,
433
- tool_result_count: 0,
434
- tool_error_count: 0, // NET real errors (harness guardrail retries excluded)
435
- harness_retry_count: 0, // benign harness guardrail retries (task 959)
436
- tokens: { input: 0, output: 0, cache_read: 0, cache_creation: 0 },
437
- model_usage: {}, // per-model token buckets for the cost-plus reward (task 1005)
438
- // task 1676: tokens from turns with NO model id (not '<synthetic>'). Folded
439
- // into a priceable bucket after the loop, then stripped from the digest.
440
- _unlabeled: { input: 0, output: 0, cache_read: 0, cache_creation: 0 },
441
- };
442
- const detail = [];
443
- let firstTs = null;
444
- let lastTs = null;
445
- let i = 0;
446
- for (const e of Array.isArray(entries) ? entries : []) {
447
- if (!e || typeof e !== 'object') continue;
448
- const ts = typeof e.timestamp === 'string' ? e.timestamp : null;
449
- if (ts) {
450
- if (!firstTs) firstTs = ts;
451
- lastTs = ts;
452
- }
453
- const msg = e.message;
454
- if (e.type === 'assistant' && msg) {
455
- m.assistant_turns++;
456
- const u = msg.usage || {};
457
- const tin = u.input_tokens || 0;
458
- const tout = u.output_tokens || 0;
459
- const tcr = u.cache_read_input_tokens || 0;
460
- const tcc = u.cache_creation_input_tokens || 0;
461
- m.tokens.input += tin;
462
- m.tokens.output += tout;
463
- m.tokens.cache_read += tcr;
464
- m.tokens.cache_creation += tcc;
465
- // task 1005: per-model bucket for the cost-plus token reward, reusing the
466
- // tin/tout/tcr/tcc already parsed above (no second pass). Skip synthetic
467
- // turns (a grader 429 etc. emits no real model id AND no real spend).
468
- const model = msg.model;
469
- if (model === '<synthetic>') {
470
- // synthetic: no real spend — exclude from BOTH model_usage and the
471
- // task-1676 unlabeled-remainder fold below (don't price phantom tokens).
472
- } else if (model) {
473
- const mu = m.model_usage[model] || (m.model_usage[model] =
474
- { input: 0, output: 0, cache_read: 0, cache_creation: 0 });
475
- mu.input += tin; mu.output += tout; mu.cache_read += tcr; mu.cache_creation += tcc;
476
- } else {
477
- // task 1676: a token-bearing turn with NO model id. Previously dropped
478
- // from model_usage (→ total_tokens>0 but priced $0 on the dev-box/CI
479
- // path; ADR 0097). Accumulate the unlabeled tokens so the digest can
480
- // attribute them to a real, priceable family bucket after the loop.
481
- m._unlabeled.input += tin; m._unlabeled.output += tout;
482
- m._unlabeled.cache_read += tcr; m._unlabeled.cache_creation += tcc;
483
- }
484
- const tools = [];
485
- let textLen = 0;
486
- let firstText = '';
487
- if (Array.isArray(msg.content)) {
488
- for (const c of msg.content) {
489
- if (c && c.type === 'tool_use') {
490
- m.tool_use_count++;
491
- tools.push(String(c.name || '?'));
492
- } else if (c && c.type === 'text' && typeof c.text === 'string') {
493
- textLen += c.text.length;
494
- if (!firstText) firstText = c.text;
495
- }
496
- }
497
- }
498
- const row = { i, role: 'assistant', ts, in: tin, out: tout, cache_read: tcr, cache_creation: tcc };
499
- if (tools.length) row.tools = tools;
500
- if (textLen) row.text_len = textLen;
501
- const snip = snippetOf(firstText, extra);
502
- if (snip) row.snippet = snip;
503
- detail.push(row);
504
- i++;
505
- } else if (e.type === 'user' && msg) {
506
- m.user_turns++;
507
- let results = 0;
508
- let errors = 0;
509
- let harnessRetries = 0;
510
- let firstResult = '';
511
- if (Array.isArray(msg.content)) {
512
- for (const c of msg.content) {
513
- if (c && c.type === 'tool_result') {
514
- results++;
515
- m.tool_result_count++;
516
- const text = typeof c.content === 'string'
517
- ? c.content
518
- : Array.isArray(c.content)
519
- ? c.content.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join(' ')
520
- : '';
521
- if (c.is_error) {
522
- // Split benign harness guardrail retries out of the net error count
523
- // so order=tool_errors ranks on real friction (task 959).
524
- if (HARNESS_RETRY_RE.test(text)) {
525
- harnessRetries++;
526
- m.harness_retry_count++;
527
- } else {
528
- errors++;
529
- m.tool_error_count++;
530
- }
531
- }
532
- if (!firstResult) firstResult = text;
533
- }
534
- }
535
- }
536
- const row = { i, role: 'user', ts };
537
- if (results) row.tool_results = results;
538
- if (errors) row.tool_errors = errors;
539
- if (harnessRetries) row.harness_retries = harnessRetries;
540
- const snip = snippetOf(firstResult, extra);
541
- if (snip) row.snippet = snip;
542
- detail.push(row);
543
- i++;
544
- }
545
- }
546
- m.total_tokens = m.tokens.input + m.tokens.output + m.tokens.cache_read + m.tokens.cache_creation;
547
- // task 1676 — cost-plus reward zero-out fix. The cost-plus session reward
548
- // (ADR 0054) prices `model_usage` server-side. model_usage is keyed on msg.model
549
- // and SKIPS any token-bearing turn whose model id is missing. On the dev-box/CI
550
- // agent path that gap was the difference between total_tokens>0 (so the digest
551
- // passes the route's empty-session guard + gets stored) and model_usage={} (so
552
- // priceModelUsage returns $0 → 0 drachmae) — ADR 0097's "upload no priced model
553
- // usage" symptom. Fold the unlabeled remainder (NOT synthetic turns) into a real,
554
- // priceable family bucket so the cost-plus reward reflects the true spend rather
555
- // than silently dropping to $0. opus is the conservative family for an unlabeled
556
- // turn (matches llm-pricing.js's unknown-model fallback) — this instance runs
557
- // opus by default, and pricing an unlabeled turn as opus never UNDER-pays.
558
- const un = m._unlabeled;
559
- if (un.input + un.output + un.cache_read + un.cache_creation > 0) {
560
- const fb = m.model_usage[UNATTRIBUTED_MODEL] || (m.model_usage[UNATTRIBUTED_MODEL] =
561
- { input: 0, output: 0, cache_read: 0, cache_creation: 0 });
562
- fb.input += un.input; fb.output += un.output;
563
- fb.cache_read += un.cache_read; fb.cache_creation += un.cache_creation;
564
- }
565
- delete m._unlabeled; // scratch — never persisted to the digest
566
- let boundedDetail = detail;
567
- if (detail.length > MAX_TURNS) {
568
- const head = Math.floor(MAX_TURNS * 0.7);
569
- const tail = MAX_TURNS - head;
570
- boundedDetail = detail.slice(0, head).concat(detail.slice(detail.length - tail));
571
- m.detail_truncated = true;
572
- m.detail_total_turns = detail.length;
573
- }
574
- let durationSeconds = null;
575
- if (firstTs && lastTs) {
576
- const d = (Date.parse(lastTs) - Date.parse(firstTs)) / 1000;
577
- if (Number.isFinite(d) && d >= 0) durationSeconds = Math.round(d);
578
- }
579
- return {
580
- startedAt: firstTs,
581
- endedAt: lastTs,
582
- durationSeconds,
583
- totalTokens: m.total_tokens,
584
- metrics: m,
585
- detail: boundedDetail,
586
- };
587
- }
588
397
 
589
398
  // classifyUploadSkip — PURE decision for whether the session upload must skip,
590
399
  // and the clear, actionable reason why. Separated from I/O so it is unit-tested
package/src/module-api.js CHANGED
@@ -1059,8 +1059,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1059
1059
  // landed since 1.19.314 with no explicit bump. run 32668969037. (task 1002620)
1060
1060
  // 1.19.316 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1061
1061
  // landed since 1.19.315 with no explicit bump. run 32669347711. (task 1002620)
1062
+ // 1.19.317 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1063
+ // landed since 1.19.316 with no explicit bump. run 32669571610. (task 1002620)
1064
+ // 1.19.318 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1065
+ // landed since 1.19.317 with no explicit bump. run 32669798745. (task 1002620)
1062
1066
  // ---------------------------------------------------------------------------
1063
- const CORE_VERSION = '1.19.316'; // CI auto-patch carrier (ADR 0161); see changelog above
1067
+ const CORE_VERSION = '1.19.318'; // CI auto-patch carrier (ADR 0161); see changelog above
1064
1068
 
1065
1069
  // A namespaced logger so a module's log lines are attributable + consistent.
1066
1070
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -34,7 +34,7 @@
34
34
  import { strict as assert } from 'node:assert';
35
35
  import { createRequire } from 'node:module';
36
36
  import { readFileSync } from 'node:fs';
37
- import { makeRunner } from './helpers.mjs';
37
+ import { makeRunner, lifecycleDbSource } from './helpers.mjs';
38
38
 
39
39
  process.env.NODE_ENV = 'test';
40
40
  const require = createRequire(import.meta.url);
@@ -113,7 +113,8 @@ await test('EXEC: every write goes through the caller-supplied exec (so a ship t
113
113
  });
114
114
 
115
115
  await test('EXEC: shipTask passes its transaction CLIENT, and the reconciler passes the pool', () => {
116
- const db = readFileSync(new URL('../modules/lifecycle/db.js', import.meta.url), 'utf8');
116
+ // task 1003204: the lifecycle data layer is db.js + db-*.js now — read the family.
117
+ const db = lifecycleDbSource();
117
118
  // db.js is over the fitness oversize threshold, so the hook is ONE line there
118
119
  // and its body lives in done-when.closeCompletedWorkForShip. What must hold is
119
120
  // that the thing handed in is the txn client, not a pool.
@@ -13,7 +13,7 @@ import { strict as assert } from 'node:assert';
13
13
  import { readFileSync } from 'node:fs';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { dirname, join } from 'node:path';
16
- import { makeRunner } from './helpers.mjs';
16
+ import { makeRunner, lifecycleDbSource } from './helpers.mjs';
17
17
 
18
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
19
  const repoRoot = join(__dirname, '..');
@@ -22,7 +22,8 @@ const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
22
22
  const mig = read('migrations', '163_polymorphic_dependencies.sql');
23
23
  const migN = mig.replace(/\s+/g, ' ');
24
24
  // the task-dependency CRUD + the claim-gate slice carved to the lifecycle module at BV1.R86.
25
- const dbSrc = read('modules', 'lifecycle', 'db.js');
25
+ // task 1003204: the lifecycle data layer is db.js + db-*.js now — read the family.
26
+ const dbSrc = lifecycleDbSource();
26
27
 
27
28
  const { test, summary } = makeRunner();
28
29
 
package/tests/fitness.mjs CHANGED
@@ -542,6 +542,49 @@ test('identity gate skips comments + instance-injected tokens (no false positive
542
542
  assert.equal(scanPromptIdentity('x.html', '<title>{{worldName}} — the gate</title>').length, 0, 'a {{worldName}} token carries no literal');
543
543
  });
544
544
 
545
+ // task 1002756 — the two holes that let migrate.sh's founder DB default survive:
546
+ // (1) the stripper could be OPENED by a `/*` inside a `//` line comment (a real
547
+ // `*/` hundreds of lines later then swallowed everything between — provision.js
548
+ // lost ~330 lines from the scanned text), and (2) whole file classes were never
549
+ // scanned. These lock the stripper; the root widening is pinned by the note test.
550
+ test('identity gate: a /* inside a line comment cannot open a phantom block (task 1002756)', () => {
551
+ const { scanPromptIdentity } = fitness;
552
+ const src = [
553
+ '// glob note: import <dir>/*.caddy', // the phantom opener shape
554
+ 'const leak = "amazonprimea.example";', // must STILL be seen
555
+ 'doThing();',
556
+ '/* a real block */ const ok = 1;',
557
+ ].join('\n');
558
+ assert.equal(scanPromptIdentity('x.js', src).length, 1, 'the literal after a /*-bearing line comment must be caught');
559
+ });
560
+
561
+ test('identity gate: a // inside a block comment does not truncate the block', () => {
562
+ const { scanPromptIdentity } = fitness;
563
+ const src = [
564
+ '/* rationale: see https://amazonprimea.com — historical // note',
565
+ 'still inside the block, amazonprimea again',
566
+ '*/',
567
+ 'const clean = 1;',
568
+ ].join('\n');
569
+ assert.equal(scanPromptIdentity('x.js', src).length, 0, 'comment prose naming the brand must not trip the scan');
570
+ });
571
+
572
+ test('identity gate: comment markers inside strings and regex literals are inert', () => {
573
+ const { scanPromptIdentity } = fitness;
574
+ // A string containing /* must not open a comment that hides a later literal…
575
+ assert.equal(scanPromptIdentity('x.js', 'const g = "src/*.js";\nconst leak = "amazonprimea";').length, 1);
576
+ // …a regex containing the brand is code and IS caught (the detectors carve out their own hits)…
577
+ assert.equal(scanPromptIdentity('x.js', 'const re = /amazonprimea/i;').length, 1);
578
+ // …and a division sign is not mistaken for a regex opener that swallows code.
579
+ assert.equal(scanPromptIdentity('x.js', 'const x = a / b / c;\nconst leak = "amazonprimea";').length, 1);
580
+ });
581
+
582
+ test('identity gate scans the WHOLE scripts/ root, not just scripts/gds (task 1002756)', () => {
583
+ const r = fitness.checkKernelPromptsNoHardcodedIdentity();
584
+ assert.match(r.note, /scripts\/\*\* \(whole root/, 'note must document the widened root');
585
+ assert.equal(r.hardFail, false, 'live tree must be clean under the widened scan:\n' + r.violations.join('\n'));
586
+ });
587
+
545
588
  // --- write-route validation build check (BV1.R12 / task 1999, ADR 0118) ---
546
589
 
547
590
  test('write-route validation check: the live tree is clean (R11 sweep reached 100%)', () => {
@@ -46,7 +46,7 @@
46
46
  import { strict as assert } from 'node:assert';
47
47
  import { createRequire } from 'node:module';
48
48
  import { readFileSync } from 'node:fs';
49
- import { makeRunner } from './helpers.mjs';
49
+ import { makeRunner, lifecycleDbSource } from './helpers.mjs';
50
50
 
51
51
  process.env.NODE_ENV = 'test';
52
52
 
@@ -201,7 +201,8 @@ await test('[Q6] non-member on a public PROTECTED-scope goal → action=apply (s
201
201
  assert.equal(r.action, 'apply', 'a protected-scope goal is never offered as open-join');
202
202
  });
203
203
  await test('[Q7] source contract: BOTH INSERT INTO claims sites run the extracted gate (batch cannot bypass)', () => {
204
- const dbSrc = readFileSync(new URL('../modules/lifecycle/db.js', import.meta.url), 'utf8');
204
+ // task 1003204: the lifecycle data layer is db.js + db-*.js now — read the family.
205
+ const dbSrc = lifecycleDbSource();
205
206
  const insertSites = dbSrc.match(/INSERT INTO claims \(/g) || [];
206
207
  assert.equal(insertSites.length, 2, 'exactly two claim-insert sites (claimTask + claimTasksBatch) — a third needs the gate too');
207
208
  const gateCalls = dbSrc.match(/await goalMembershipFailures\(/g) || [];
@@ -17,7 +17,7 @@ import { strict as assert } from 'node:assert';
17
17
  import { createRequire } from 'node:module';
18
18
  import { readFileSync } from 'node:fs';
19
19
  import http from 'node:http';
20
- import { makeRunner } from './helpers.mjs';
20
+ import { makeRunner, lifecycleDbSource } from './helpers.mjs';
21
21
 
22
22
  process.env.NODE_ENV = 'test';
23
23
 
@@ -25,7 +25,8 @@ const require = createRequire(import.meta.url);
25
25
  const { adviseGoal, candidateGoals, generalGoalTitle, buildGoalAdvisory } = require('../modules/lifecycle/goal-advisory.js');
26
26
 
27
27
  const tasksSrc = readFileSync(new URL('../modules/lifecycle/routes/tasks.js', import.meta.url), 'utf8');
28
- const dbSrc = readFileSync(new URL('../modules/lifecycle/db.js', import.meta.url), 'utf8');
28
+ // task 1003204: the lifecycle data layer is db.js + db-*.js now — read the family.
29
+ const dbSrc = lifecycleDbSource();
29
30
  const { test, summary } = makeRunner();
30
31
 
31
32
  const V = 'BONGOS-V1';
@@ -37,6 +37,7 @@ const LIFECYCLE = path.join(ROOT, 'modules', 'lifecycle');
37
37
  const eligibility = require(path.join(LIFECYCLE, 'claim-eligibility.js'));
38
38
  const db = require(path.join(LIFECYCLE, 'db.js'));
39
39
  const catalog = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
40
+ import { lifecycleDbSource } from './helpers.mjs';
40
41
 
41
42
  const { CLAIM_ANY, CLAIM_NEWCOMER, permissionAllowsClaim, claimEligibilityAllows } = eligibility;
42
43
 
@@ -147,7 +148,8 @@ test('the permission predicate cannot see requires_rank at all (structural)', ()
147
148
  });
148
149
 
149
150
  test('every claim path in db.js still gates on requires_rank as its own check', () => {
150
- const src = fs.readFileSync(path.join(LIFECYCLE, 'db.js'), 'utf8');
151
+ // task 1003204: the lifecycle data layer is db.js + db-*.js now — read the family.
152
+ const src = lifecycleDbSource();
151
153
  // claimTask, claimTasksBatch, validateClaimBatch — three call sites, three
152
154
  // separate rankAllowsTask conjuncts. If a refactor ever folds the floor into the
153
155
  // permission predicate, this count drops and the test reds.
@@ -18,6 +18,7 @@ import { createRequire } from 'node:module';
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
+ import { lifecycleDbSource } from './helpers.mjs';
21
22
 
22
23
  const require = createRequire(import.meta.url);
23
24
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -189,8 +190,14 @@ test('the writer runs on the CALLER\'s transaction client when given one', () =>
189
190
  });
190
191
 
191
192
  test('both rank-change twins rotate the role inside their transaction', () => {
192
- for (const rel of ['src/bongos/db.js', 'modules/lifecycle/db.js']) {
193
- const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
193
+ // task 1003204: the lifecycle twin moved out of db.js into db-rank-authz.js
194
+ // when the monolith was carved up. Read the whole lifecycle family so the
195
+ // assertion follows the code instead of a filename.
196
+ const twins = [
197
+ ['src/bongos/db.js', fs.readFileSync(path.join(ROOT, 'src/bongos/db.js'), 'utf8')],
198
+ ['modules/lifecycle/db-*.js', lifecycleDbSource()],
199
+ ];
200
+ for (const [rel, src] of twins) {
194
201
  const body = src.slice(src.indexOf('async function _applyRankChangeTx'));
195
202
  const upd = body.indexOf('UPDATE builders SET rank');
196
203
  const sync = body.indexOf('await syncGovernanceRankRole(client');
package/tests/helpers.mjs CHANGED
@@ -123,3 +123,22 @@ export function requireHostModule(require, spec, note) {
123
123
  throw err;
124
124
  }
125
125
  }
126
+
127
+ // lifecycleDbSource — the LIFECYCLE data layer as one source string.
128
+ //
129
+ // task 1003204 (goal 1000079) split the 5,748-line modules/lifecycle/db.js into
130
+ // db.js (a pure re-export facade) + twelve cohesive db-*.js units. A pile of
131
+ // tests assert things about "the data layer" by regexing its source — that the
132
+ // claim paths gate on requires_rank, that a literal appears in exactly one
133
+ // place, that dependency CRUD points at the right table. Those assertions were
134
+ // always about the LAYER, not about one filename, so they read the whole family
135
+ // here. Reading the family (rather than repointing each test at whichever db-*
136
+ // unit happens to hold the code today) also means a future re-carve moves code
137
+ // between units without silently disarming the assertion.
138
+ export function lifecycleDbSource(modulesDir = path.join(ROOT, 'modules')) {
139
+ const dir = path.join(modulesDir, 'lifecycle');
140
+ const files = fs.readdirSync(dir)
141
+ .filter((f) => f === 'db.js' || (f.startsWith('db-') && f.endsWith('.js')))
142
+ .sort();
143
+ return files.map((f) => fs.readFileSync(path.join(dir, f), 'utf8')).join('\n');
144
+ }
@@ -28,6 +28,7 @@ import { readFileSync } from 'node:fs';
28
28
  import { fileURLToPath } from 'node:url';
29
29
  import { dirname, join } from 'node:path';
30
30
  import { createRequire } from 'node:module';
31
+ import { lifecycleDbSource } from './helpers.mjs';
31
32
 
32
33
  const __dirname = dirname(fileURLToPath(import.meta.url));
33
34
  const repoRoot = join(__dirname, '..');
@@ -49,7 +50,8 @@ async function test(name, fn) {
49
50
  const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
50
51
  const migrationSql = read('migrations', '081_tasks_needs_migration.sql');
51
52
  // the tasks db slice + the tasks route carved to modules/lifecycle/ at BV1.R86.
52
- const dbSrc = read('modules', 'lifecycle', 'db.js');
53
+ // task 1003204: the lifecycle data layer is db.js + db-*.js now — read the family.
54
+ const dbSrc = lifecycleDbSource();
53
55
  const routesSrc = read('modules', 'lifecycle', 'routes', 'tasks.js');
54
56
  const captureSrc = read('scripts', 'gds', 'capture.js');
55
57
 
@@ -16,6 +16,7 @@ import { createRequire } from 'node:module';
16
16
 
17
17
  const require = createRequire(import.meta.url);
18
18
  const reconciler = require('../modules/lifecycle/publish-reconciler.js');
19
+ import { lifecycleDbSource } from './helpers.mjs';
19
20
 
20
21
  let passed = 0;
21
22
  let failed = 0;
@@ -1647,7 +1648,8 @@ test('vector 2: shipped_at is stamped ONLY by shipTask, and shipTask requires co
1647
1648
  const url = await import('node:url');
1648
1649
  const path = await import('node:path');
1649
1650
  const ROOT = path.dirname(path.dirname(url.fileURLToPath(import.meta.url)));
1650
- const dbSrc = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/db.js'), 'utf8');
1651
+ // task 1003204: the lifecycle data layer is db.js + db-*.js now — read the family.
1652
+ const dbSrc = lifecycleDbSource();
1651
1653
 
1652
1654
  // Every statement that writes shipped_at, ignoring SELECT projections and comments.
1653
1655
  const writes = [...dbSrc.matchAll(/shipped_at\s*=\s*now\(\)/g)];
@@ -1693,3 +1695,41 @@ setTimeout(() => {
1693
1695
  console.log(`\npublish reconciler (task 1134 + 1915) tests: ${passed} passed, ${failed} failed`);
1694
1696
  process.exit(failed > 0 ? 1 : 0);
1695
1697
  }, 100);
1698
+
1699
+ // --- task 1003217: a sweep that only RAISED rows must not log silence --------
1700
+
1701
+ test('1003217 runCostSweep reports raised rows (a raise-only sweep still logs)', async () => {
1702
+ const logs = [];
1703
+ const deps = {
1704
+ db: { reconcileSessionCosts: async () => ({ scanned: 12, reconciled: 0, raised: 3, totalUsd: 45, unknownModels: [] }) },
1705
+ log: (m) => logs.push(m),
1706
+ error: () => {},
1707
+ };
1708
+ const r = await reconciler.runCostSweep(deps);
1709
+ assert.equal(r.raised, 3, 'the raised count passes through');
1710
+ assert.equal(logs.length, 1, 'a sweep that raised 3 rows logged — the old guard was reconciled>0 only');
1711
+ assert.match(logs[0], /raised 3/, 'and the raise is named in the line');
1712
+ });
1713
+
1714
+ test('1003217 a sweep that did nothing at all still logs nothing', async () => {
1715
+ const logs = [];
1716
+ const deps = {
1717
+ db: { reconcileSessionCosts: async () => ({ scanned: 12, reconciled: 0, raised: 0, totalUsd: 0, unknownModels: [] }) },
1718
+ log: (m) => logs.push(m),
1719
+ error: () => {},
1720
+ };
1721
+ await reconciler.runCostSweep(deps);
1722
+ assert.equal(logs.length, 0, 'no ledger work → no line, as before');
1723
+ });
1724
+
1725
+ test('1003217 an older deps bundle with no `raised` key still logs on reconciled', async () => {
1726
+ const logs = [];
1727
+ const deps = {
1728
+ db: { reconcileSessionCosts: async () => ({ scanned: 2, reconciled: 2, totalUsd: 3, unknownModels: [] }) },
1729
+ log: (m) => logs.push(m),
1730
+ error: () => {},
1731
+ };
1732
+ await reconciler.runCostSweep(deps);
1733
+ assert.equal(logs.length, 1);
1734
+ assert.doesNotMatch(logs[0], /raised/, 'no phantom "raised 0" noise when the key is absent');
1735
+ });
@@ -26,6 +26,7 @@ const require = createRequire(import.meta.url);
26
26
  const auth = require('../src/bongos/auth.js');
27
27
  const db = require('../modules/lifecycle/db.js');
28
28
  const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
29
+ import { lifecycleDbSource } from './helpers.mjs';
29
30
 
30
31
  let passed = 0;
31
32
  let failed = 0;
@@ -319,7 +320,8 @@ console.log('\nlistClaimableTasks SQL rank-tier CASE (regression: thetes drift):
319
320
 
320
321
  const __dir = dirname(fileURLToPath(import.meta.url));
321
322
  // listClaimableTasks carved to the lifecycle module at BV1.R86 — read the SQL there.
322
- const dbSrc = readFileSync(join(__dir, '..', 'modules', 'lifecycle', 'db.js'), 'utf8');
323
+ // task 1003204: the lifecycle data layer is db.js + db-*.js now — read the family.
324
+ const dbSrc = lifecycleDbSource();
323
325
 
324
326
  t('every live rank appears in the claimable CASE at its REQUIRES_RANK_TIER value', () => {
325
327
  // Isolate the listClaimableTasks function body so we don't match CASE
@@ -25,6 +25,7 @@ const db = require('../modules/lifecycle/db.js');
25
25
  // the SAME singleton object db.js captured, so mutating its .query stubs the INSERT
26
26
  // createTask runs — no database needed.
27
27
  const sharedPool = require('../src/module-api.js').pool;
28
+ import { lifecycleDbSource } from './helpers.mjs';
28
29
 
29
30
  const __dir = dirname(fileURLToPath(import.meta.url));
30
31
  const root = join(__dir, '..');
@@ -132,7 +133,8 @@ t('null task is a no-op (no throw, no assignment)', () => {
132
133
  console.log('\nenforcement points (source-wiring — AUTO-ASSIGN):');
133
134
 
134
135
  const tasksRouteSrc = readFileSync(join(root, 'modules/lifecycle/routes/tasks.js'), 'utf8');
135
- const dbSrc = readFileSync(join(root, 'modules/lifecycle/db.js'), 'utf8');
136
+ // task 1003204: the lifecycle data layer is db.js + db-*.js now — read the family.
137
+ const dbSrc = lifecycleDbSource();
136
138
  const migration178 = readFileSync(join(root, 'migrations/178_reward_gate_auto_assign_revert_autopromote.sql'), 'utf8');
137
139
 
138
140
  t('promote route AUTO-ASSIGNS then proceeds (no 409 refusal for missing reward)', () => {
@@ -131,3 +131,67 @@ test('1344 addComponents / addTiers / roundTiers are pure and additive', () => {
131
131
  const rounded = sc.roundTiers({ fresh_input_usd: 1.000049, cache_read_usd: 0, cache_write_usd: 0, output_usd: 0 });
132
132
  assert.equal(rounded.fresh_input_usd, 1.0); // round to 4dp
133
133
  });
134
+
135
+ // ---------- task 1003217: price one API call once, not once per line ----------
136
+ //
137
+ // A single assistant message is written to the transcript as several lines (one
138
+ // per content block: thinking / text / tool_use), each repeating the SAME
139
+ // message.usage. priceTranscript must dedup on message.id. Measured 2.22x
140
+ // over-pricing on 12 real transcripts before this fix — and ADR 0054 pays
141
+ // round(true_cost_usd x 1.20) off this number.
142
+ function splitLine(id, model, usage, block) {
143
+ return JSON.stringify({ message: { id, role: 'assistant', model, usage, content: [block] } });
144
+ }
145
+
146
+ test('1003217 priceTranscript charges a split assistant message ONCE', () => {
147
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-dedup-'));
148
+ const p = path.join(dir, 'split.jsonl');
149
+ const usage = { output_tokens: M }; // opus-5 output = $25/M
150
+ fs.writeFileSync(p, [
151
+ splitLine('msg_1', 'claude-opus-5', usage, { type: 'thinking', thinking: 'hmm' }),
152
+ splitLine('msg_1', 'claude-opus-5', usage, { type: 'text', text: 'ok' }),
153
+ splitLine('msg_1', 'claude-opus-5', usage, { type: 'tool_use', name: 'Bash' }),
154
+ ].join('\n'));
155
+ const r = sc.priceTranscript(p);
156
+ assert.equal(r.total, 25, 'one call at 1M opus output = $25, NOT $75');
157
+ });
158
+
159
+ test('1003217 priceTranscript still charges distinct message ids separately', () => {
160
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-dedup2-'));
161
+ const p = path.join(dir, 'three.jsonl');
162
+ const usage = { output_tokens: M };
163
+ fs.writeFileSync(p, [
164
+ splitLine('msg_1', 'claude-opus-5', usage, { type: 'text', text: 'a' }),
165
+ splitLine('msg_2', 'claude-opus-5', usage, { type: 'text', text: 'b' }),
166
+ splitLine('msg_3', 'claude-opus-5', usage, { type: 'text', text: 'c' }),
167
+ ].join('\n'));
168
+ assert.equal(sc.priceTranscript(p).total, 75, 'three real calls = $75');
169
+ });
170
+
171
+ test('1003217 priceTranscript keeps the tier split consistent with the deduped total', () => {
172
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-dedup3-'));
173
+ const p = path.join(dir, 'tiers.jsonl');
174
+ const usage = { output_tokens: M, cache_read_input_tokens: M };
175
+ fs.writeFileSync(p, [
176
+ splitLine('msg_1', 'claude-opus-5', usage, { type: 'text', text: 'a' }),
177
+ splitLine('msg_1', 'claude-opus-5', usage, { type: 'tool_use', name: 'Bash' }),
178
+ ].join('\n'));
179
+ const r = sc.priceTranscript(p);
180
+ // opus-5: output $25/M, cache_read $0.50/M → $25.50 for ONE call.
181
+ assert.equal(r.total, 25.5);
182
+ assert.equal(r.tiers.output_usd, 25);
183
+ assert.equal(r.tiers.cache_read_usd, 0.5);
184
+ const tierSum = r.tiers.fresh_input_usd + r.tiers.cache_read_usd
185
+ + r.tiers.cache_write_usd + r.tiers.output_usd;
186
+ assert.ok(Math.abs(tierSum - r.total) < 1e-9, 'the four tiers must still sum to total');
187
+ });
188
+
189
+ test('1003217 a transcript with no message.id prices per line (unchanged legacy path)', () => {
190
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-dedup4-'));
191
+ const p = path.join(dir, 'noid.jsonl');
192
+ fs.writeFileSync(p, [
193
+ JSON.stringify({ message: { role: 'assistant', model: 'claude-opus-5', usage: { output_tokens: M } } }),
194
+ JSON.stringify({ message: { role: 'assistant', model: 'claude-opus-5', usage: { output_tokens: M } } }),
195
+ ].join('\n'));
196
+ assert.equal(sc.priceTranscript(p).total, 50, 'no id to dedup on → both lines charged');
197
+ });