@bongos/core 1.19.443 → 1.19.445

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 (33) hide show
  1. package/.bongos-core.json +120 -20
  2. package/docs/adr/0054-session-token-reward-cost-plus.md +31 -0
  3. package/docs/design/hall-direction-v2.md +295 -0
  4. package/docs/design/mocks/hall/_tokens.css +370 -0
  5. package/docs/design/mocks/hall/board/v1.html +846 -0
  6. package/docs/design/mocks/hall/board/v2.html +858 -0
  7. package/docs/design/mocks/hall/board/v3.html +884 -0
  8. package/docs/design/mocks/hall/goals/v1.html +1212 -0
  9. package/docs/design/mocks/hall/goals/v2.html +1192 -0
  10. package/docs/design/mocks/hall/goals/v3.html +1205 -0
  11. package/docs/design/mocks/hall/hall-sheet.html +244 -0
  12. package/docs/design/mocks/hall/reading/v1.html +1125 -0
  13. package/docs/design/mocks/hall/reading/v2.html +1177 -0
  14. package/docs/design/mocks/hall/reading/v3.html +1150 -0
  15. package/docs/design/mocks/hall/settings/v1.html +1238 -0
  16. package/docs/design/mocks/hall/settings/v2.html +1235 -0
  17. package/docs/design/mocks/hall/settings/v3.html +1299 -0
  18. package/docs/design/mocks/hall/task/v1.html +637 -0
  19. package/docs/design/mocks/hall/task/v2.html +646 -0
  20. package/docs/design/mocks/hall/task/v3.html +651 -0
  21. package/docs/design/vanilla-hall-ui-redesign-scope.md +2 -0
  22. package/docs/module-api-changelog.md +4 -0
  23. package/modules/economy/credits.js +46 -13
  24. package/package-lock.json +2 -2
  25. package/package.json +1 -1
  26. package/scripts/gds/backfill-subagent-rewards.js +485 -0
  27. package/scripts/gds/session-digest-build.js +49 -17
  28. package/scripts/gds/session-digest.js +11 -1
  29. package/scripts/gds/ship-session-upload.js +102 -12
  30. package/scripts/gds/ship.js +7 -1
  31. package/src/module-api.js +1 -1
  32. package/tests/backfill_subagent_rewards.mjs +308 -0
  33. package/tests/credit_grant.mjs +236 -0
@@ -588,6 +588,242 @@ await test('cost-plus-only cascade: a 0 per-task credit → idea-promotion slice
588
588
  assert.equal(client.queries.length, 0, 'no lineage lookup, no bonus INSERT when the base credit is 0');
589
589
  });
590
590
 
591
+ // =========================================================================
592
+ // (7) task 1003435 — a PARALLEL session (one claim, several subagents).
593
+ //
594
+ // The cost ledger has always billed a session's subagents subtree in its own
595
+ // `session:subagent` cost_log row, but model_usage — the reward's only input —
596
+ // was built from ONE file, the interactive transcript. So a builder who followed
597
+ // CLAUDE.md's "delegate freely" moved most of a session's real spend into a
598
+ // bucket the reward could not see, and the two ledgers described different
599
+ // sessions.
600
+ //
601
+ // The acceptance test is that RECONCILIATION: what the reward prices must equal
602
+ // what the ledger bills, within independent-rounding epsilon. It runs both real
603
+ // pricing paths over the same on-disk fixture rather than asserting a hand-typed
604
+ // number on either side, so a change to one that is not made to the other reds.
605
+ // =========================================================================
606
+
607
+ const fs = require('node:fs');
608
+ const os = require('node:os');
609
+ const nodePath = require('node:path');
610
+ const { priceTranscript, priceSubagents, buildCostRows } = require('../scripts/gds/session-cost.js');
611
+ const { buildSessionDigest } = require('../scripts/gds/session-digest-build.js');
612
+ const { foldSubagentUsage } = require('../scripts/gds/ship-session-upload.js');
613
+ const { priceModelUsage } = require('../src/bongos/llm-pricing.js');
614
+
615
+ const PARALLEL_SID = 'sess-parallel';
616
+
617
+ // One assistant transcript line. `id` is the API call's identity — two lines
618
+ // sharing one are ONE call, which is what makes the dedup assertion below real.
619
+ function asstLine(id, model, usage) {
620
+ return JSON.stringify({
621
+ type: 'assistant',
622
+ timestamp: '2026-08-30T00:00:00.000Z',
623
+ message: { id, model, usage, content: [{ type: 'text', text: 'work' }] },
624
+ });
625
+ }
626
+ function userLine(text) {
627
+ return JSON.stringify({
628
+ type: 'user',
629
+ timestamp: '2026-08-30T00:00:00.000Z',
630
+ message: { role: 'user', content: text },
631
+ });
632
+ }
633
+ function parseJsonlText(raw) {
634
+ return raw.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
635
+ }
636
+
637
+ // A session that spawned three subagents, one of them a grader. Mirrors the real
638
+ // on-disk layout: the interactive transcript is a FILE, and the subagents live in
639
+ // a sibling DIRECTORY named for the session id.
640
+ function makeParallelFixture() {
641
+ const dir = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'otb-parallel-'));
642
+ const mainPath = nodePath.join(dir, `${PARALLEL_SID}.jsonl`);
643
+ // opus: 150k in / 30k out / 1.5M read / 40k write = $0.75+$0.75+$0.75+$0.25 = $2.50.
644
+ // m1 is written twice (one API call, two content blocks) — priced ONCE.
645
+ const dup = asstLine('m1', 'claude-opus-4-8', {
646
+ input_tokens: 100_000, output_tokens: 20_000,
647
+ cache_read_input_tokens: 1_000_000, cache_creation_input_tokens: 40_000,
648
+ });
649
+ fs.writeFileSync(mainPath, [
650
+ userLine('ship task 1003435'),
651
+ dup,
652
+ dup,
653
+ asstLine('m2', 'claude-opus-4-8', {
654
+ input_tokens: 50_000, output_tokens: 10_000,
655
+ cache_read_input_tokens: 500_000, cache_creation_input_tokens: 0,
656
+ }),
657
+ ].join('\n'));
658
+
659
+ const subagents = nodePath.join(dir, PARALLEL_SID, 'subagents');
660
+ const nested = nodePath.join(subagents, 'nested');
661
+ fs.mkdirSync(nested, { recursive: true });
662
+ // opus subagent: 200k in / 40k out / 2M read = $1.00+$1.00+$1.00 = $3.00
663
+ fs.writeFileSync(nodePath.join(subagents, 'agent-a.jsonl'), [
664
+ userLine('audit the pricing path'),
665
+ asstLine('a1', 'claude-opus-4-8', {
666
+ input_tokens: 200_000, output_tokens: 40_000, cache_read_input_tokens: 2_000_000,
667
+ }),
668
+ ].join('\n'));
669
+ // sonnet subagent, one level deeper — the walk is recursive:
670
+ // 100k in / 20k out / 1M read = $0.20+$0.20+$0.20 = $0.60
671
+ fs.writeFileSync(nodePath.join(nested, 'agent-b.jsonl'), [
672
+ userLine('read the ledger rows'),
673
+ asstLine('b1', 'claude-sonnet-5', {
674
+ input_tokens: 100_000, output_tokens: 20_000, cache_read_input_tokens: 1_000_000,
675
+ }),
676
+ ].join('\n'));
677
+ // A GRADER worker nested in the subagents dir. Its $5.00 is already captured via
678
+ // the worker envelope, so pricing it here would double-count (ADR 0028).
679
+ fs.writeFileSync(nodePath.join(nested, 'grader-worker.jsonl'), [
680
+ userLine('You are the **Hacker** — a specialist adversarial subagent. Find the holes.'),
681
+ asstLine('g1', 'claude-opus-4-8', { input_tokens: 1_000_000 }),
682
+ ].join('\n'));
683
+
684
+ return { dir, mainPath, sessionDir: nodePath.join(dir, PARALLEL_SID) };
685
+ }
686
+
687
+ const fixture = makeParallelFixture();
688
+ const INTERACTIVE_USD = 2.50;
689
+ const SUBAGENT_USD = 3.60;
690
+ const SESSION_USD = INTERACTIVE_USD + SUBAGENT_USD; // $6.10
691
+ // Each cost_log row is rounded to 4dp independently and priceModelUsage rounds
692
+ // its own total, so the two sides may disagree by a rounding step per row.
693
+ const EPSILON = 0.0002;
694
+
695
+ // The digest the ship uploads for this session, subagents folded in.
696
+ function parallelDigest() {
697
+ const digest = buildSessionDigest(parseJsonlText(fs.readFileSync(fixture.mainPath, 'utf8')));
698
+ foldSubagentUsage(digest, fixture.mainPath, PARALLEL_SID);
699
+ return digest;
700
+ }
701
+
702
+ await test('1003435 the cost ledger bills interactive + subagent, grader excluded', () => {
703
+ const inter = priceTranscript(fixture.mainPath);
704
+ const sub = priceSubagents(fixture.sessionDir);
705
+ assert.equal(Math.round(inter.total * 100) / 100, INTERACTIVE_USD, 'm1 priced once, not twice');
706
+ assert.equal(Math.round(sub.subagentTotal * 100) / 100, SUBAGENT_USD);
707
+ assert.equal(sub.subagentFiles, 2);
708
+ assert.equal(sub.graderSkipped, 1, 'the grader worker is skipped by signature');
709
+ const built = buildCostRows({ sessionId: PARALLEL_SID, inter, sub });
710
+ assert.equal(built.rows.length, 2, 'one :interactive row + one :subagent row');
711
+ assert.equal(Math.round(built.rowsTotal * 100) / 100, SESSION_USD);
712
+ });
713
+
714
+ await test('1003435 model_usage now carries the subagents subtree, grader excluded', () => {
715
+ const digest = parallelDigest();
716
+ const models = Object.keys(digest.metrics.model_usage).sort();
717
+ assert.deepEqual(models, ['claude-opus-4-8', 'claude-sonnet-5'],
718
+ 'the sonnet subagent reached model_usage, which only the fold can do');
719
+ assert.equal(digest.metrics.subagent_files, 2);
720
+ assert.equal(digest.metrics.subagent_grader_skipped, 1);
721
+ // The grader's 1M opus input tokens must be in NEITHER ledger.
722
+ assert.equal(digest.metrics.model_usage['claude-opus-4-8'].input, 350_000,
723
+ '150k interactive + 200k subagent — the grader\'s 1M is not counted');
724
+ });
725
+
726
+ await test('1003435 detail + total_tokens stay the INTERACTIVE thread\'s record', () => {
727
+ const interactiveOnly = buildSessionDigest(
728
+ parseJsonlText(fs.readFileSync(fixture.mainPath, 'utf8'))
729
+ );
730
+ const digest = parallelDigest();
731
+ // The fold is model_usage ONLY: detail and the turn/token metrics feed the BFG
732
+ // corpus + inefficiency evaluator, which reason about ONE agent's turn-by-turn
733
+ // behaviour. Splicing N subagent transcripts into that would distort the very
734
+ // signal they measure, so the subagent_* counters carry the difference instead.
735
+ assert.equal(digest.detail.length, interactiveOnly.detail.length);
736
+ assert.equal(digest.totalTokens, interactiveOnly.totalTokens);
737
+ assert.equal(digest.metrics.assistant_turns, interactiveOnly.metrics.assistant_turns);
738
+ });
739
+
740
+ await test('1003435 ACCEPTANCE: the reward reconciles with the session\'s cost_log rows', () => {
741
+ const inter = priceTranscript(fixture.mainPath);
742
+ const sub = priceSubagents(fixture.sessionDir);
743
+ const ledgerUsd = buildCostRows({ sessionId: PARALLEL_SID, inter, sub }).rowsTotal;
744
+ const rewardUsd = priceModelUsage(parallelDigest().metrics.model_usage).true_cost_usd;
745
+ assert.ok(Math.abs(rewardUsd - ledgerUsd) <= EPSILON,
746
+ `reward priced $${rewardUsd} but the ledger billed $${ledgerUsd}`);
747
+ });
748
+
749
+ await test('1003435 a parallel session is PAID for its subagents', async () => {
750
+ const client = fakeClient();
751
+ client.enqueue(NO_PRIOR);
752
+ client.enqueue([{ id: 99 }]);
753
+ const digest = parallelDigest();
754
+ const res = await awardSessionTokenReward(
755
+ client, 7, PARALLEL_SID, digest.metrics.model_usage, digest.totalTokens
756
+ );
757
+ assert.equal(res.awarded, true);
758
+ assert.equal(res.trueCostUsd, SESSION_USD);
759
+ assert.equal(res.drachmae, Math.round(SESSION_USD * 1.20)); // $6.10 × 1.20 = 7.32 → 7
760
+ // The bug this closes: interactive-only priced the same session at $2.50 → 3.
761
+ const before = computeSessionTokenReward(INTERACTIVE_USD).drachmae;
762
+ assert.ok(res.drachmae > before, `subagent-blind reward was ${before}, now ${res.drachmae}`);
763
+ });
764
+
765
+ await test('1003435 a re-ship of the same session is still a no-op (one award per session)', async () => {
766
+ const digest = parallelDigest();
767
+ const client = fakeClient();
768
+ // The watermark already holds everything this session is worth.
769
+ client.enqueue(ledger(Math.round(SESSION_USD * 1.20), 0));
770
+ const res = await awardSessionTokenReward(
771
+ client, 7, PARALLEL_SID, digest.metrics.model_usage, digest.totalTokens
772
+ );
773
+ assert.equal(res.awarded, false);
774
+ assert.equal(res.drachmae, 0);
775
+ assert.equal(client.queries.length, 1, 'SELECT only — a second upload must not pay again');
776
+ });
777
+
778
+ await test('1003435 a traversing session id folds NOTHING (the walk feeds a payout)', () => {
779
+ // The id is interpolated into a filesystem path whose contents get priced, so
780
+ // a `../` value could aim the walk at another directory and fold fabricated
781
+ // tokens into the reward. The on-ship caller screens the id before resolving
782
+ // the transcript; the SessionEnd hook takes it straight off stdin — so the
783
+ // guard lives in foldSubagentUsage, where every caller gets it.
784
+ const entries = parseJsonlText(fs.readFileSync(fixture.mainPath, 'utf8'));
785
+ const clean = buildSessionDigest(entries).metrics.model_usage;
786
+ for (const hostile of ['../' + PARALLEL_SID, `${PARALLEL_SID}/..`, 'a/../../etc', '']) {
787
+ const digest = foldSubagentUsage(buildSessionDigest(entries), fixture.mainPath, hostile);
788
+ assert.deepEqual(digest.metrics.model_usage, clean, `folded for id ${JSON.stringify(hostile)}`);
789
+ assert.equal(digest.metrics.subagent_files, undefined);
790
+ }
791
+ // …and the legitimate id still folds, so the guard is not just refusing everything.
792
+ const ok = foldSubagentUsage(buildSessionDigest(entries), fixture.mainPath, PARALLEL_SID);
793
+ assert.equal(ok.metrics.subagent_files, 2);
794
+ });
795
+
796
+ await test('1003435 usageOnly changes what is BUILT, never what is COUNTED', () => {
797
+ // The fold reuses buildSessionDigest so the token semantics — dedup by
798
+ // message.id, skip <synthetic>, fold unlabeled turns — have ONE home. usageOnly
799
+ // only drops the per-turn rows it would discard (and their snippet scrubbing,
800
+ // which the SessionEnd watchdog gives a hard time budget). If it ever changed a
801
+ // count, the reward and the ledger would quietly diverge again.
802
+ const entries = parseJsonlText(fs.readFileSync(fixture.mainPath, 'utf8'));
803
+ const full = buildSessionDigest(entries);
804
+ const lean = buildSessionDigest(entries, { usageOnly: true });
805
+ assert.deepEqual(lean.metrics.model_usage, full.metrics.model_usage);
806
+ assert.equal(lean.totalTokens, full.totalTokens);
807
+ assert.equal(lean.metrics.assistant_turns, full.metrics.assistant_turns);
808
+ assert.equal(lean.metrics.api_calls, full.metrics.api_calls);
809
+ assert.deepEqual(lean.detail, [], 'the detail rows are what usageOnly skips');
810
+ assert.ok(full.detail.length > 0);
811
+ });
812
+
813
+ await test('1003435 a session with no subagents is untouched (no regression)', () => {
814
+ const dir = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'otb-solo-'));
815
+ const mainPath = nodePath.join(dir, 'sess-solo.jsonl');
816
+ fs.writeFileSync(mainPath, asstLine('s1', 'claude-opus-4-8', { input_tokens: 110_000 }));
817
+ const entries = parseJsonlText(fs.readFileSync(mainPath, 'utf8'));
818
+ const before = buildSessionDigest(entries);
819
+ const after = foldSubagentUsage(buildSessionDigest(entries), mainPath, 'sess-solo');
820
+ assert.deepEqual(after.metrics.model_usage, before.metrics.model_usage);
821
+ assert.equal(after.metrics.subagent_files, undefined, 'no counters when there is nothing to count');
822
+ fs.rmSync(dir, { recursive: true, force: true });
823
+ });
824
+
825
+ fs.rmSync(fixture.dir, { recursive: true, force: true });
826
+
591
827
  // -------------------------------------------------------------------------
592
828
  // runner summary
593
829
  // -------------------------------------------------------------------------