@bongos/core 1.19.176 → 1.19.178

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.176",
6
- "core_contract": "1.19.176",
7
- "source_commit": "9b6c711a358a67610bdb81e8776bc689a0b9cdd8",
5
+ "core_version": "1.19.178",
6
+ "core_contract": "1.19.178",
7
+ "source_commit": "039017134d5f7aee3cc1df34e7238847f878d51c",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-16T20:39:00.850Z",
9
+ "built_at": "2026-08-16T20:52:11.709Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 306,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1273,
14
+ "functional_verbatim": 1274,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1600,
20
- "tree_sha256": "fe346cc282c27c1ec0f17e6dc13d3a82795f14343fa9e42f120b3df9a3138546",
19
+ "file_count": 1601,
20
+ "tree_sha256": "c2eb9f930075434353ce4f1fccd3189b5a30229ff5083e7fd9b4f7ee57508e45",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -2832,7 +2832,7 @@
2832
2832
  {
2833
2833
  "path": "modules/economy/credits.js",
2834
2834
  "mode": "0000644",
2835
- "sha256": "38526b1d7a095fc48d062ddd645df074f381d2ef0e0aac6593ff306601659904"
2835
+ "sha256": "b6f8c6fcabe6cb9f5eb83e936d129509004c86969d5e0d16a9653ff06b88f7ac"
2836
2836
  },
2837
2837
  {
2838
2838
  "path": "modules/economy/migrations/economy_001_fix_gds_shipper_description.sql",
@@ -2847,7 +2847,7 @@
2847
2847
  {
2848
2848
  "path": "modules/economy/reward.js",
2849
2849
  "mode": "0000644",
2850
- "sha256": "2c0e17de0497d642dd28be0d810dd24bfcfc263fd805612f2f0cc05bc330f1d8"
2850
+ "sha256": "2a354daf06e344a613c8ad7df12ed6e534a9920f49b9f62b54e29b64ead59546"
2851
2851
  },
2852
2852
  {
2853
2853
  "path": "modules/economy/routes/cost.js",
@@ -3477,7 +3477,7 @@
3477
3477
  {
3478
3478
  "path": "modules/ideas/module.json",
3479
3479
  "mode": "0000644",
3480
- "sha256": "7aa38c4dc690735cea2c1ab1ea61b9fa34281ec6f3505ed091990949f741b77d"
3480
+ "sha256": "64116b6abe3b2e13e3e6697b5c53e12534de698376410e2a14e319d4836481be"
3481
3481
  },
3482
3482
  {
3483
3483
  "path": "modules/ideas/routes/blockers.js",
@@ -3487,7 +3487,7 @@
3487
3487
  {
3488
3488
  "path": "modules/ideas/routes/inbox.js",
3489
3489
  "mode": "0000644",
3490
- "sha256": "8604c627617cc90fa3197dcf3776ce708546d754b9acc2d45c16dfea97a478cb"
3490
+ "sha256": "d6307869ae1b4388aa33244a1807f74ca2442122dea597e2f1ea5b7fe4533495"
3491
3491
  },
3492
3492
  {
3493
3493
  "path": "modules/ideas/templates.js",
@@ -4227,12 +4227,12 @@
4227
4227
  {
4228
4228
  "path": "package-lock.json",
4229
4229
  "mode": "0000644",
4230
- "sha256": "07c720a5ef0a0e1e93727688e9c6c87e00ab43f289efa7cbb8eebea6f3cdbba9"
4230
+ "sha256": "1048f3d5207f880d23989332e8eebcaf55edcbd117046b2e72e6ce65f9fc5f42"
4231
4231
  },
4232
4232
  {
4233
4233
  "path": "package.json",
4234
4234
  "mode": "0000644",
4235
- "sha256": "d014a9180d2a66087029d00f9f10da48fbc8ee3be7a88db11280bc35da7fd611"
4235
+ "sha256": "8792a5baa25590aacff1a8ff0ea6690fce9d800d1ac1ce74fc64f998e2038c3c"
4236
4236
  },
4237
4237
  {
4238
4238
  "path": "public-docs/index.html",
@@ -5642,7 +5642,7 @@
5642
5642
  {
5643
5643
  "path": "src/bongos/llm-pricing.js",
5644
5644
  "mode": "0000644",
5645
- "sha256": "c421166d7d5099f96b2b46cf9fb46b2c0b3594fb59d318c1a512751bd00a9969"
5645
+ "sha256": "f7688e75a873f9c287f6559311877081830798cb1b5f1e548ceff3fcbed9e31d"
5646
5646
  },
5647
5647
  {
5648
5648
  "path": "src/bongos/metrics.js",
@@ -5837,7 +5837,7 @@
5837
5837
  {
5838
5838
  "path": "src/module-api.js",
5839
5839
  "mode": "0000644",
5840
- "sha256": "9459525829ea38080db6cc5e757ef7be130c3da00c6563324e892bf605313d8d"
5840
+ "sha256": "56077ebaf7d23e7e524497ae087473677b8a7ca34e5695ccbf0a54544884be83"
5841
5841
  },
5842
5842
  {
5843
5843
  "path": "src/module-loader/catalog.js",
@@ -6367,7 +6367,7 @@
6367
6367
  {
6368
6368
  "path": "tests/cost_reconcile.mjs",
6369
6369
  "mode": "0000644",
6370
- "sha256": "a94de0a9ebd15b8aeb80fa2fc976d847334e733bd0c2a65a869bac5c4b6b4dd8"
6370
+ "sha256": "8356250d8fc753afb2960d867f81586531053afd15f4e66da1a1b7b62b6b5db7"
6371
6371
  },
6372
6372
  {
6373
6373
  "path": "tests/cost_summary_shape.mjs",
@@ -6964,6 +6964,11 @@
6964
6964
  "mode": "0000644",
6965
6965
  "sha256": "95b53f1acfa61f723741b4d33b5e882525903ccc549ae01f3094a788251a60ec"
6966
6966
  },
6967
+ {
6968
+ "path": "tests/idea_full_pass_credit.mjs",
6969
+ "mode": "0000644",
6970
+ "sha256": "1ae98ae22202e6a6dbf42a48e0133654eeda32eec36b697c59f6b9d630936817"
6971
+ },
6967
6972
  {
6968
6973
  "path": "tests/idea_grades.mjs",
6969
6974
  "mode": "0000644",
@@ -7102,7 +7107,7 @@
7102
7107
  {
7103
7108
  "path": "tests/llm-pricing.mjs",
7104
7109
  "mode": "0000644",
7105
- "sha256": "dec4e9ab447bfac75c6298c6f47d0f0f85e643c7519e192ca704d9a93a834499"
7110
+ "sha256": "de3fabaeafb3eb3ad500becf565f3b6d7118a763f1e80d085f94b6ba990d5c58"
7106
7111
  },
7107
7112
  {
7108
7113
  "path": "tests/local_preview.mjs",
@@ -107,6 +107,17 @@ const KIND_MULTIPLIERS = Object.freeze({
107
107
  });
108
108
  const IDEA_BONUS_FRACTION = 0.25; // 25% slice to the idea's original capturer
109
109
 
110
+ // =========================================================================
111
+ // The IDEATOR credit lane — stream 2 (ADR 0172 / goal 1000067 IC2).
112
+ // =========================================================================
113
+ // A Full Idea that clears the completeness bar pays its author a flat credit,
114
+ // ONCE. This is the ideator lane, NOT the builder per-task estimate — it gates
115
+ // on config.ideatorCredit, never perTaskCredit, so cost-plus-only does not
116
+ // suppress it (that is the whole point of ADR 0172). The other two streams
117
+ // (per-task-ship IC4, governance-pass IC5) will be siblings of this function.
118
+ const IDEA_FULL_PASS_CREDIT = 5; // owner: "gives 5 credits"
119
+ const IDEA_FULL_PASS_BAR = 60; // completeness 0-100; ADR 0172 proposed default
120
+
110
121
  // =========================================================================
111
122
  // Session token reward (flat cost-plus) — task 1005 (ADR 0054).
112
123
  // =========================================================================
@@ -321,6 +332,59 @@ async function maybeAwardIdeaPromotionBonus(client, taskId, shipperBuilderId, mu
321
332
  };
322
333
  }
323
334
 
335
+ // Stream 2 (ADR 0172 / IC2): a Full Idea with a passing completeness score pays
336
+ // its author IDEA_FULL_PASS_CREDIT, ONCE. Transaction-participant like the rest
337
+ // of the reward port — the caller passes its `client` (a pool works too, since
338
+ // the write is a single idempotent statement).
339
+ //
340
+ // client — pg client or pool
341
+ // ideaId, authorId — the idea and its captured_by builder
342
+ // completenessScore — the R142 score (0-100) or null (unscorable)
343
+ // config — resolved reward policy; gates on config.ideatorCredit
344
+ //
345
+ // Returns { credited, reason }:
346
+ // { credited: 5, reason: 'paid' } — first pass, booked
347
+ // { credited: 0, reason: 'already_paid' } — a prior row for this idea exists
348
+ // { credited: 0, reason: 'below_bar' } — null or < IDEA_FULL_PASS_BAR
349
+ // { credited: 0, reason: 'ideator_lane_off' } — instance opted the lane out
350
+ //
351
+ // IDEMPOTENCY is the reason string: `idea.credit.full_pass:<ideaId>` is unique
352
+ // per idea, and the INSERT ... WHERE NOT EXISTS is a single atomic statement, so
353
+ // a re-grade / re-capture / concurrent request can never double-pay — no unique
354
+ // constraint or extra column on the core credit_log table needed (which a module
355
+ // migration may not add anyway). task_id is NULL: an idea credit has no task.
356
+ async function awardIdeaFullPassCredit(client, { ideaId, authorId, completenessScore, config = rewardConfig() } = {}) {
357
+ if (config && config.ideatorCredit === false) {
358
+ return { credited: 0, reason: 'ideator_lane_off' };
359
+ }
360
+ if (completenessScore == null || !Number.isFinite(Number(completenessScore)) || Number(completenessScore) < IDEA_FULL_PASS_BAR) {
361
+ return { credited: 0, reason: 'below_bar' };
362
+ }
363
+ if (authorId == null || ideaId == null) {
364
+ return { credited: 0, reason: 'missing_author_or_idea' };
365
+ }
366
+ const reason = `idea.credit.full_pass:${ideaId}`;
367
+ const description = `Full Idea #${ideaId} passed (completeness ${Math.round(Number(completenessScore))}/100) → ${IDEA_FULL_PASS_CREDIT}c to its author (ideator lane, ADR 0172)`;
368
+ // Idempotency predicate is `builder_id = author AND reason = <per-idea key>`.
369
+ // The `builder_id` term is NOT for correctness — the reason string is already
370
+ // globally unique per idea — it exists to hit the existing
371
+ // `idx_credit_log_builder (builder_id, recorded_at)` index: the NOT EXISTS
372
+ // narrows to the author's handful of credit rows (index scan) instead of a
373
+ // sequential scan of a credit_log that grows with total platform activity.
374
+ // credit_log is a CORE table, so a module migration cannot add an index on
375
+ // `reason` (the additive-namespace rule) — reusing the builder index is the
376
+ // in-scope fix. (R143/IC2 grader finding.)
377
+ const { rowCount } = await client.query(
378
+ `INSERT INTO credit_log (builder_id, task_id, delta, reason, description)
379
+ SELECT $1, NULL, $2, $3, $4
380
+ WHERE NOT EXISTS (SELECT 1 FROM credit_log WHERE builder_id = $1 AND reason = $3)`,
381
+ [authorId, IDEA_FULL_PASS_CREDIT, reason, description]
382
+ );
383
+ return rowCount > 0
384
+ ? { credited: IDEA_FULL_PASS_CREDIT, reason: 'paid' }
385
+ : { credited: 0, reason: 'already_paid' };
386
+ }
387
+
324
388
  // Award the flat cost-plus session token reward, idempotent per session_id
325
389
  // (task 1005, ADR 0054). Called inside the same transaction as the session
326
390
  // upsert (see upsertSessionRecordWithReward).
@@ -531,6 +595,9 @@ module.exports = {
531
595
  applyKindMultiplier,
532
596
  insertMultipliedConfirmCredit,
533
597
  maybeAwardIdeaPromotionBonus,
598
+ awardIdeaFullPassCredit,
599
+ IDEA_FULL_PASS_CREDIT,
600
+ IDEA_FULL_PASS_BAR,
534
601
  awardSessionTokenReward,
535
602
  NET_NEGATIVE_BONUS,
536
603
  SUBTRACTOR_THRESHOLD,
@@ -24,6 +24,8 @@ module.exports = {
24
24
  // credit allocation — transaction-participant: caller passes its tx `client`
25
25
  insertMultipliedConfirmCredit: (...a) => credits.insertMultipliedConfirmCredit(...a),
26
26
  maybeAwardIdeaPromotionBonus: (...a) => credits.maybeAwardIdeaPromotionBonus(...a),
27
+ // ADR 0172 IC2 — the ideator credit lane (stream 2): a passing Full Idea pays its author, once.
28
+ awardIdeaFullPassCredit: (...a) => credits.awardIdeaFullPassCredit(...a),
27
29
  awardNetNegativeBonus: (...a) => credits.awardNetNegativeBonus(...a),
28
30
  awardSessionTokenReward: (...a) => credits.awardSessionTokenReward(...a),
29
31
  // gamification hooks the lifecycle fires at claim/ship
@@ -1,11 +1,22 @@
1
1
  {
2
2
  "key": "ideas",
3
3
  "title": "Ideas",
4
- "description": "The idea inbox (capture, triage, criterion-proposal ratification) and the blockers queue — the methodology surfaces that feed the backlog. A core-domain module carved out of the GDS core in BV1.R78 (ADR 0093 §1); default-on because every instance captures ideas and tracks what's stuck on its owner. Registers the `ideas.capture` kernel PORT the discord #ideas inbound resolves (ADR 0093 §5 — retiring the temporary `captureIdea` doorway leak).",
4
+ "description": "The idea inbox (capture, triage, criterion-proposal ratification) and the blockers queue \u2014 the methodology surfaces that feed the backlog. A core-domain module carved out of the GDS core in BV1.R78 (ADR 0093 \u00a71); default-on because every instance captures ideas and tracks what's stuck on its owner. Registers the `ideas.capture` kernel PORT the discord #ideas inbound resolves (ADR 0093 \u00a75 \u2014 retiring the temporary `captureIdea` doorway leak).",
5
5
  "coreVersion": "^1.8.0",
6
6
  "default": true,
7
- "maintenance": { "status": "core-maintained" },
8
- "contributes": { "routes": ["inbox", "blockers"] },
9
- "provides": ["ideas.capture"],
10
- "consumes": []
7
+ "maintenance": {
8
+ "status": "core-maintained"
9
+ },
10
+ "contributes": {
11
+ "routes": [
12
+ "inbox",
13
+ "blockers"
14
+ ]
15
+ },
16
+ "provides": [
17
+ "ideas.capture"
18
+ ],
19
+ "consumes": [
20
+ "reward"
21
+ ]
11
22
  }
@@ -120,6 +120,32 @@ module.exports = function buildInboxRouter() {
120
120
  idea.template_key = row.template_key;
121
121
  idea.field_values = row.field_values;
122
122
  idea.completeness_score = row.completeness_score;
123
+
124
+ // Stream 2 (ADR 0172 / IC2): a Full Idea that clears the completeness
125
+ // bar pays its author, once, on the IDEATOR credit lane — a separate
126
+ // reward from the builder estimate, so cost-plus-only does NOT
127
+ // suppress it. Resolved through the economy `reward` port (this
128
+ // module declares `reward` in its consumes), never a direct import.
129
+ // FAIL-OPEN, inside the same guard: a credit hiccup must never lose
130
+ // the capture — the idea is already stored. The port method is
131
+ // idempotent (reason = idea.credit.full_pass:<id>), so this cannot
132
+ // double-pay on a re-capture. The pool is the tx client — the write
133
+ // is one atomic idempotent statement.
134
+ try {
135
+ const reward = api.resolveOptional && api.resolveOptional('reward');
136
+ if (reward && reward.awardIdeaFullPassCredit) {
137
+ const r = await reward.awardIdeaFullPassCredit(api.pool, {
138
+ ideaId: idea.id,
139
+ authorId: req.builder.id,
140
+ completenessScore: row.completeness_score,
141
+ });
142
+ if (r && r.credited > 0) idea.ideator_credit = r.credited;
143
+ }
144
+ } catch (creditErr) {
145
+ console.error('[gds] POST /inbox full-idea pass-credit failed (idea captured + graded, credit skipped):', creditErr);
146
+ // no warning surfaced — the idea and its grade are intact; the credit
147
+ // is the only thing missed, and a reconcile/re-grade path can pay it.
148
+ }
123
149
  } catch (gradeErr) {
124
150
  console.error('[gds] POST /inbox full-grade write failed (idea captured, landing as quick):', gradeErr);
125
151
  grade_warning = 'captured_but_ungraded';
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.176",
3
+ "version": "1.19.178",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.176",
9
+ "version": "1.19.178",
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.176",
3
+ "version": "1.19.178",
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",
@@ -34,20 +34,42 @@
34
34
  // Family-based pricing assumes the CURRENT model in each family (what this
35
35
  // instance runs). A historical id like claude-opus-4-1 ($15/$75) would be
36
36
  // mis-priced low here — acceptable: the platform only runs 4.x-current models.
37
+ //
38
+ // VERIFIED 2026-08-16 against the live Anthropic pricing page (task 1002883):
39
+ // added the `fable` family. familyFor() previously matched only opus/sonnet/
40
+ // haiku, so claude-fable-5 — the owner's top-model default and this instance's
41
+ // `assumed_builder_model` (modules/grading/grader-rubric.json) — fell through to
42
+ // null and was priced at the opus fallback with unknown_model=true. Fable is
43
+ // 2× opus, so every cost-plus reward computed from a Fable session was priced at
44
+ // HALF its true cost (not conservative: cost-plus UNDER-pays the builder). The
45
+ // grading side already carried the family token (grader.js MODEL_FAMILY_TOKENS);
46
+ // this table is the surface that never caught up.
47
+ // - fable = Claude Fable 5 / Mythos 5 ($10 in / $50 out) — identical rates,
48
+ // which is why one family token covers both.
49
+ // - opus = Claude Opus 5 / 4.8 / 4.7 ($5 in / $25 out) [re-verified]
50
+ // - sonnet = Claude Sonnet 4.6 ($3 in / $15 out) [re-verified]
51
+ // - haiku = Claude Haiku 4.5 ($1 in / $5 out) [re-verified]
52
+ // NOTE (deliberately NOT changed here — its own task, per the rule above):
53
+ // Claude Sonnet 5 is now $2/$10, not the $3/$15 this `sonnet` row carries. The
54
+ // grader's default worker model is claude-sonnet-5, so grade costs are currently
55
+ // OVER-priced ~1.5×. That is a separate price-table decision; see task 1003001.
37
56
  const PRICES = {
38
57
  // family : { input, output, cache_write_5m, cache_write_1h, cache_read } (USD / 1e6 tokens)
58
+ fable: { input: 10, output: 50, cache_write_5m: 12.5, cache_write_1h: 20, cache_read: 1 },
39
59
  opus: { input: 5, output: 25, cache_write_5m: 6.25, cache_write_1h: 10, cache_read: 0.5 },
40
60
  sonnet: { input: 3, output: 15, cache_write_5m: 3.75, cache_write_1h: 6, cache_read: 0.3 },
41
61
  haiku: { input: 1, output: 5, cache_write_5m: 1.25, cache_write_1h: 2, cache_read: 0.1 },
42
62
  };
43
63
 
44
- // Resolve a model id (e.g. 'claude-opus-4-8', 'claude-sonnet-4-6',
45
- // 'claude-haiku-4-5') to a price family by substring — mirrors grader.js's
46
- // family-token convention (all live Claude ids contain one of these three).
47
- // Unknown → null (caller decides: we conservatively price at the opus rate AND
48
- // flag it, so an unpriced model is loud, never silently $0).
64
+ // Resolve a model id (e.g. 'claude-fable-5', 'claude-opus-4-8',
65
+ // 'claude-sonnet-4-6', 'claude-haiku-4-5') to a price family by substring —
66
+ // mirrors grader.js's MODEL_FAMILY_TOKENS convention, including the fable/mythos
67
+ // pair it checks first (all live Claude ids contain one of these tokens).
68
+ // Unknown → null (caller decides: we price at the opus rate AND flag it, so an
69
+ // unpriced model is loud, never silently $0 — see priceUsage on why opus).
49
70
  function familyFor(model) {
50
71
  const m = String(model || '').toLowerCase();
72
+ if (m.includes('fable') || m.includes('mythos')) return 'fable';
51
73
  if (m.includes('opus')) return 'opus';
52
74
  if (m.includes('sonnet')) return 'sonnet';
53
75
  if (m.includes('haiku')) return 'haiku';
@@ -70,7 +92,14 @@ function priceUsage(model, usage) {
70
92
  const u = usage || {};
71
93
  const fam = familyFor(model);
72
94
  const unknownModel = fam === null;
73
- const priceFamily = fam || 'opus'; // conservative default for an unrecognized model
95
+ // Unrecognized → price at the opus rate. Since `fable` landed (task 1002883)
96
+ // opus is no longer the ceiling, so this is a MID-range fallback, not a
97
+ // conservative one: an unknown Fable-class model prices at half its true rate.
98
+ // That is why unknown_model=true rides along — the loudness is the safeguard,
99
+ // not the rate. (Deliberately left at opus: every model this platform actually
100
+ // runs is now matched, so raising the fallback would only over-price genuinely
101
+ // foreign ids like gpt-4o.)
102
+ const priceFamily = fam || 'opus';
74
103
  const p = PRICES[priceFamily];
75
104
 
76
105
  const input = Number(u.input_tokens) || 0; // uncached input
@@ -179,7 +208,7 @@ function priceModelUsage(modelUsage) {
179
208
  * Like priceModelUsage, model_usage has NO 5m/1h cache-creation breakdown — the
180
209
  * `cache_creation` lump is priced at the 5m rate, exactly as session-cost.js
181
210
  * priceTranscript does in its `else` branch (no breakdown → 5m). Unknown model
182
- * ids are conservatively priced as opus AND collected, so an unpriced model is
211
+ * ids are priced as opus AND collected, so an unpriced model is
183
212
  * loud, never silently $0. Pure: no I/O, no Date.now().
184
213
  *
185
214
  * @param {Object<string, {input?:number,output?:number,cache_read?:number,cache_creation?:number}>} modelUsage
@@ -199,7 +228,7 @@ function componentsFromModelUsage(modelUsage) {
199
228
  const usage = u || {};
200
229
  const fam = familyFor(rawId);
201
230
  if (fam === null) unknown.push(canonicalizeModelId(rawId));
202
- const p = PRICES[fam || 'opus']; // conservative default for an unrecognized model
231
+ const p = PRICES[fam || 'opus']; // mid-range fallback; unknown ids ride the `unknown` list
203
232
  fresh_input_usd += ((Number(usage.input) || 0) * p.input) / 1e6;
204
233
  output_usd += ((Number(usage.output) || 0) * p.output) / 1e6;
205
234
  cache_read_usd += ((Number(usage.cache_read) || 0) * p.cache_read) / 1e6;
package/src/module-api.js CHANGED
@@ -779,8 +779,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
779
779
  // landed since 1.19.174 with no explicit bump. run 31970199477. (task 1002620)
780
780
  // 1.19.176 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
781
781
  // landed since 1.19.175 with no explicit bump. run 31971125373. (task 1002620)
782
+ // 1.19.177 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
783
+ // landed since 1.19.176 with no explicit bump. run 31971532260. (task 1002620)
784
+ // 1.19.178 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
785
+ // landed since 1.19.177 with no explicit bump. run 31971769062. (task 1002620)
782
786
  // ---------------------------------------------------------------------------
783
- const CORE_VERSION = '1.19.176'; // CI auto-patch carrier (ADR 0161); see changelog above
787
+ const CORE_VERSION = '1.19.178'; // CI auto-patch carrier (ADR 0161); see changelog above
784
788
 
785
789
  // A namespaced logger so a module's log lines are attributable + consistent.
786
790
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -100,3 +100,33 @@ test('1366 missing per-tier fields default to 0 (partial model_usage)', () => {
100
100
  assert.equal(out.cache_write_usd, 0);
101
101
  assert.ok(near(out.total_usd, 5));
102
102
  });
103
+
104
+ // ---------- fable / mythos family (task 1002883) ----------
105
+ // The reconciler is the SECOND pricing path the missing fable family broke: a
106
+ // cloud-box / overnight Fable session whose client cost hook never posted lands
107
+ // here, and before the fix it re-priced at the opus fallback — half the true
108
+ // cost, and flagged unknown on the platform's own default model.
109
+
110
+ test('1002883 fable 1M/1M/10M/1M → $10 / $50 / $10 / $12.50, total $82.50, not flagged', () => {
111
+ const out = componentsFromModelUsage({
112
+ 'claude-fable-5': { input: M, output: M, cache_read: 10 * M, cache_creation: M },
113
+ });
114
+ assert.ok(near(out.fresh_input_usd, 10), `fresh ${out.fresh_input_usd}`);
115
+ assert.ok(near(out.output_usd, 50), `output ${out.output_usd}`);
116
+ assert.ok(near(out.cache_read_usd, 10), `cache_read ${out.cache_read_usd}`); // 10M * 1/M
117
+ assert.ok(near(out.cache_write_usd, 12.5), `cache_write ${out.cache_write_usd}`);
118
+ assert.ok(near(out.total_usd, 82.5), `total ${out.total_usd}`);
119
+ const sum = out.fresh_input_usd + out.cache_read_usd + out.cache_write_usd + out.output_usd;
120
+ assert.ok(near(sum, out.total_usd), `tiers sum ${sum} must equal total ${out.total_usd}`);
121
+ assert.deepEqual(out.unknown_models, [], 'fable is the platform default — never "unknown"');
122
+ });
123
+
124
+ test('1002883 mythos prices as fable, and a fable session is 2× the same opus session', () => {
125
+ const usage = { input: M, output: M };
126
+ const fable = componentsFromModelUsage({ 'claude-fable-5': usage });
127
+ const mythos = componentsFromModelUsage({ 'claude-mythos-5': usage });
128
+ const opus = componentsFromModelUsage({ 'claude-opus-5': usage });
129
+ assert.ok(near(fable.total_usd, mythos.total_usd), 'mythos shares fable rates');
130
+ assert.deepEqual(mythos.unknown_models, []);
131
+ assert.ok(near(fable.total_usd, opus.total_usd * 2), `fable ${fable.total_usd} != 2× opus ${opus.total_usd}`);
132
+ });
@@ -0,0 +1,113 @@
1
+ // tests/idea_full_pass_credit.mjs
2
+ //
3
+ // task 1002996 (BV1.R172 / IC2, goal 1000067) — stream 2 of the ideator credit
4
+ // lane: a Full Idea with a passing completeness score pays its author 5 credits,
5
+ // once. ADR 0172.
6
+ //
7
+ // The done-when as assertions: a passing Full Idea books exactly 5; a thin /
8
+ // null-scored one books 0; re-running never double-pays; cost-plus-only does NOT
9
+ // suppress it (it gates on ideatorCredit, never perTaskCredit); an instance that
10
+ // opts the ideator lane out pays nothing.
11
+ //
12
+ // The credit fn (awardIdeaFullPassCredit) is exercised over a stub client that
13
+ // records statements and simulates the WHERE-NOT-EXISTS idempotency, so the
14
+ // idempotency logic is proven without a DB. Pure module import.
15
+ //
16
+ // Run: node tests/idea_full_pass_credit.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 credits = require('../modules/economy/credits.js');
23
+
24
+ let passed = 0;
25
+ let failed = 0;
26
+ async function test(name, fn) {
27
+ try { await fn(); passed++; console.log(` ok ${name}`); }
28
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
29
+ }
30
+
31
+ // A stub client whose credit_log "table" is an in-memory set of reasons, so the
32
+ // INSERT ... WHERE NOT EXISTS (SELECT 1 ... WHERE reason=$3) behaves exactly like
33
+ // Postgres: the second insert of the same reason is a no-op (rowCount 0).
34
+ function stubClient() {
35
+ const rows = [];
36
+ return {
37
+ rows,
38
+ async query(sql, params) {
39
+ // our fn issues exactly one INSERT ... SELECT ... WHERE NOT EXISTS, whose
40
+ // subquery filters on builder_id AND reason (the index-friendly predicate).
41
+ // Mirror both terms so the stub matches the real query.
42
+ const builderId = params[0];
43
+ const reason = params[2];
44
+ assert.match(sql, /builder_id = \$1 AND reason = \$3/, 'idempotency check must scope by builder_id to hit the index');
45
+ const exists = rows.some((r) => r.builder_id === builderId && r.reason === reason);
46
+ if (exists) return { rowCount: 0, rows: [] };
47
+ rows.push({ builder_id: builderId, delta: params[1], reason, description: params[3] });
48
+ return { rowCount: 1, rows: [] };
49
+ },
50
+ };
51
+ }
52
+
53
+ const PASS = 72; // clears the 60 bar
54
+ const THIN = 40; // below the bar
55
+
56
+ await test('a passing Full Idea books exactly 5 credits to its author', async () => {
57
+ const c = stubClient();
58
+ const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 9, authorId: 3, completenessScore: PASS, config: { ideatorCredit: true } });
59
+ assert.equal(r.credited, credits.IDEA_FULL_PASS_CREDIT);
60
+ assert.equal(r.reason, 'paid');
61
+ assert.equal(c.rows.length, 1);
62
+ assert.equal(c.rows[0].delta, 5);
63
+ assert.equal(c.rows[0].builder_id, 3);
64
+ assert.equal(c.rows[0].reason, 'idea.credit.full_pass:9');
65
+ });
66
+
67
+ await test('re-running pays nothing — idempotent per idea', async () => {
68
+ const c = stubClient();
69
+ const opts = { ideaId: 9, authorId: 3, completenessScore: PASS, config: { ideatorCredit: true } };
70
+ const first = await credits.awardIdeaFullPassCredit(c, opts);
71
+ const second = await credits.awardIdeaFullPassCredit(c, opts);
72
+ const third = await credits.awardIdeaFullPassCredit(c, opts);
73
+ assert.equal(first.credited, 5);
74
+ assert.equal(second.credited, 0);
75
+ assert.equal(second.reason, 'already_paid');
76
+ assert.equal(third.credited, 0);
77
+ assert.equal(c.rows.length, 1, 'exactly one credit_log row ever, across three calls');
78
+ });
79
+
80
+ await test('a below-bar or null score books 0 — scoring gates the pay, not capture', async () => {
81
+ const c = stubClient();
82
+ assert.equal((await credits.awardIdeaFullPassCredit(c, { ideaId: 1, authorId: 3, completenessScore: THIN, config: { ideatorCredit: true } })).reason, 'below_bar');
83
+ assert.equal((await credits.awardIdeaFullPassCredit(c, { ideaId: 2, authorId: 3, completenessScore: null, config: { ideatorCredit: true } })).reason, 'below_bar');
84
+ assert.equal((await credits.awardIdeaFullPassCredit(c, { ideaId: 3, authorId: 3, completenessScore: undefined, config: { ideatorCredit: true } })).reason, 'below_bar');
85
+ assert.equal(c.rows.length, 0, 'nothing written for any sub-bar score');
86
+ });
87
+
88
+ await test('COST-PLUS-ONLY does NOT suppress it: the ideator lane pays regardless of perTaskCredit', async () => {
89
+ // the live cloudbongos.com policy: builder estimate off, ideator lane on
90
+ const policy = credits.rewardPolicy({ mode: 'cost-plus-only' });
91
+ assert.equal(policy.perTaskCredit, false);
92
+ assert.equal(policy.ideatorCredit, true);
93
+ const c = stubClient();
94
+ const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 5, authorId: 3, completenessScore: PASS, config: policy });
95
+ assert.equal(r.credited, 5, 'paid despite cost-plus-only — this is the whole point of ADR 0172');
96
+ });
97
+
98
+ await test('an instance that opts the ideator lane out pays nothing', async () => {
99
+ const c = stubClient();
100
+ const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 6, authorId: 3, completenessScore: PASS, config: { ideatorCredit: false } });
101
+ assert.equal(r.credited, 0);
102
+ assert.equal(r.reason, 'ideator_lane_off');
103
+ assert.equal(c.rows.length, 0);
104
+ });
105
+
106
+ await test('a missing author or idea is a safe 0, never a throw', async () => {
107
+ const c = stubClient();
108
+ assert.equal((await credits.awardIdeaFullPassCredit(c, { ideaId: 6, authorId: null, completenessScore: PASS, config: { ideatorCredit: true } })).reason, 'missing_author_or_idea');
109
+ assert.equal((await credits.awardIdeaFullPassCredit(c, { ideaId: null, authorId: 3, completenessScore: PASS, config: { ideatorCredit: true } })).reason, 'missing_author_or_idea');
110
+ });
111
+
112
+ console.log(`\nidea_full_pass_credit: ${passed} passed, ${failed} failed`);
113
+ if (failed > 0) process.exit(1);
@@ -2,6 +2,9 @@
2
2
  // Prices VERIFIED 2026-06-20 against the live Anthropic models/pricing page
3
3
  // (GDS-V4 task 1343): opus (4.8/4.7) $5/$25, sonnet (4.6) $3/$15, haiku (4.5)
4
4
  // $1/$5; flat across the 1M window (no long-context premium).
5
+ // Re-VERIFIED + extended 2026-08-16 (task 1002883): fable (Fable 5 / Mythos 5)
6
+ // $10/$50 — the family familyFor() used to miss, so Fable sessions priced at the
7
+ // opus fallback and cost-plus under-paid by half.
5
8
  // Run: node tests/llm-pricing.mjs
6
9
 
7
10
  import { strict as assert } from 'node:assert';
@@ -18,6 +21,9 @@ const M = 1_000_000;
18
21
  const near = (a, b) => Math.abs(a - b) < 1e-6;
19
22
 
20
23
  test('familyFor matches by substring; unknown → null', () => {
24
+ assert.equal(familyFor('claude-fable-5'), 'fable');
25
+ assert.equal(familyFor('claude-mythos-5'), 'fable'); // same rates → same family
26
+ assert.equal(familyFor('claude-mythos-preview'), 'fable');
21
27
  assert.equal(familyFor('claude-opus-4-8'), 'opus');
22
28
  assert.equal(familyFor('claude-sonnet-4-6'), 'sonnet');
23
29
  assert.equal(familyFor('claude-haiku-4-5'), 'haiku');
@@ -26,6 +32,68 @@ test('familyFor matches by substring; unknown → null', () => {
26
32
  assert.equal(familyFor(undefined), null);
27
33
  });
28
34
 
35
+ // ---------- fable / mythos family (task 1002883) ----------
36
+ // The regression this locks: claude-fable-5 is the owner's top-model default and
37
+ // the grader's assumed_builder_model, but familyFor() matched only opus/sonnet/
38
+ // haiku — so it fell to the opus fallback (unknown_model=true) at HALF its rate,
39
+ // and every cost-plus reward from a Fable session under-paid the builder.
40
+
41
+ test('fable 5: 1M input + 1M output = $10 + $50 = $60, and is NOT flagged unknown', () => {
42
+ const r = priceUsage('claude-fable-5', { input_tokens: M, output_tokens: M });
43
+ assert.ok(near(r.usd, 60), `got ${r.usd}`);
44
+ assert.equal(r.unknown_model, false, 'fable must resolve, not ride the opus fallback');
45
+ assert.equal(r.family, 'fable');
46
+ });
47
+
48
+ test('mythos 5 prices identically to fable 5 (one family, shared rates)', () => {
49
+ const f = priceUsage('claude-fable-5', { input_tokens: M, output_tokens: M });
50
+ const m = priceUsage('claude-mythos-5', { input_tokens: M, output_tokens: M });
51
+ assert.ok(near(f.usd, m.usd), `fable ${f.usd} != mythos ${m.usd}`);
52
+ assert.equal(m.family, 'fable');
53
+ });
54
+
55
+ test('fable cache tiers: read $1, 5m write $12.50, 1h write $20 per 1M', () => {
56
+ assert.ok(near(priceUsage('claude-fable-5', { cache_read_input_tokens: M }).usd, 1));
57
+ assert.ok(near(priceUsage('claude-fable-5', {
58
+ cache_creation_input_tokens: M,
59
+ cache_creation: { ephemeral_5m_input_tokens: M, ephemeral_1h_input_tokens: 0 },
60
+ }).usd, 12.5));
61
+ assert.ok(near(priceUsage('claude-fable-5', {
62
+ cache_creation_input_tokens: M,
63
+ cache_creation: { ephemeral_5m_input_tokens: 0, ephemeral_1h_input_tokens: M },
64
+ }).usd, 20));
65
+ });
66
+
67
+ test('fable rides the published cache multipliers off its own base input (1.25× / 2× / 0.1×)', () => {
68
+ const { fable } = PRICES;
69
+ assert.ok(near(fable.cache_write_5m, fable.input * 1.25));
70
+ assert.ok(near(fable.cache_write_1h, fable.input * 2));
71
+ assert.ok(near(fable.cache_read, fable.input * 0.1));
72
+ });
73
+
74
+ test('fable is exactly 2× opus — the size of the pre-fix cost-plus shortfall', () => {
75
+ const usage = { input_tokens: M, output_tokens: M };
76
+ const fable = priceUsage('claude-fable-5', usage).usd;
77
+ const opus = priceUsage('claude-opus-5', usage).usd;
78
+ assert.ok(near(fable, opus * 2), `fable ${fable} != 2× opus ${opus}`);
79
+ });
80
+
81
+ test('priceModelUsage: a fable session costs $60 / 1M+1M and reports no unknown models', () => {
82
+ const r = priceModelUsage({ 'claude-fable-5': { input: M, output: M } });
83
+ assert.ok(near(r.true_cost_usd, 60), `got ${r.true_cost_usd}`);
84
+ assert.deepEqual(r.unknown_models, [], 'fable must not be reported as unpriced');
85
+ });
86
+
87
+ test('canonicalizeModelId collapses fable variants without changing the price', () => {
88
+ assert.equal(canonicalizeModelId('claude-fable-5[1m]'), 'claude-fable-5');
89
+ const r = priceModelUsage({
90
+ 'claude-fable-5': { input: M },
91
+ 'claude-fable-5[1m]': { input: M },
92
+ });
93
+ assert.equal(r.per_model.length, 2, 'raw keys kept as separate breakdown rows');
94
+ assert.ok(near(r.true_cost_usd, 20), `got ${r.true_cost_usd}`); // 10 + 10
95
+ });
96
+
29
97
  test('opus 4.8: 1M input + 1M output = $5 + $25 = $30', () => {
30
98
  const r = priceUsage('claude-opus-4-8', { input_tokens: M, output_tokens: M });
31
99
  assert.ok(near(r.usd, 30), `got ${r.usd}`);