@bongos/core 1.19.1065 → 1.19.1066
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 +25 -20
- package/docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md +1 -0
- package/docs/branding-contract.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/modules/copy-desk/page-status.js +4 -2
- package/modules/copy-desk/routes/copy-desk.js +3 -2
- package/modules/economy/credits.js +77 -0
- package/modules/economy/reward-policy.js +7 -1
- package/modules/economy/reward.js +4 -0
- package/modules/lifecycle/db-ship.js +14 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/artist_page_credit.mjs +272 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.1066",
|
|
6
|
+
"core_contract": "1.19.1066",
|
|
7
|
+
"source_commit": "75a6b51abf5c23eb683f15eb0d4f8a7387803a8f",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-28T16:
|
|
9
|
+
"built_at": "2026-09-28T16:56:08.158Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 546,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2557,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3130,
|
|
20
|
+
"tree_sha256": "2f41769464ed9567654e2b042a9946102caa4a0e4cdf1d9bf93ccee84aa79855",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2172,7 +2172,7 @@
|
|
|
2172
2172
|
{
|
|
2173
2173
|
"path": "docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md",
|
|
2174
2174
|
"mode": "0000644",
|
|
2175
|
-
"sha256": "
|
|
2175
|
+
"sha256": "058c9eb62763fd8c0459277a7ab904010d79589027f8f4920dd6de6ad938dc3b"
|
|
2176
2176
|
},
|
|
2177
2177
|
{
|
|
2178
2178
|
"path": "docs/adr/README.md",
|
|
@@ -2197,7 +2197,7 @@
|
|
|
2197
2197
|
{
|
|
2198
2198
|
"path": "docs/branding-contract.md",
|
|
2199
2199
|
"mode": "0000644",
|
|
2200
|
-
"sha256": "
|
|
2200
|
+
"sha256": "40630abe085d54562abbeea3b8c046c03b4245363a2a37c4eea086940684e19f"
|
|
2201
2201
|
},
|
|
2202
2202
|
{
|
|
2203
2203
|
"path": "docs/branding-fill-prompt.md",
|
|
@@ -2747,7 +2747,7 @@
|
|
|
2747
2747
|
{
|
|
2748
2748
|
"path": "docs/module-api-changelog.md",
|
|
2749
2749
|
"mode": "0000644",
|
|
2750
|
-
"sha256": "
|
|
2750
|
+
"sha256": "ea2d1bdda788f09e2665dd8a924c78e9618197b6e602ef1f2dfaddd96b8ca938"
|
|
2751
2751
|
},
|
|
2752
2752
|
{
|
|
2753
2753
|
"path": "docs/modules-contract.md",
|
|
@@ -4152,7 +4152,7 @@
|
|
|
4152
4152
|
{
|
|
4153
4153
|
"path": "modules/copy-desk/page-status.js",
|
|
4154
4154
|
"mode": "0000644",
|
|
4155
|
-
"sha256": "
|
|
4155
|
+
"sha256": "0b96c931e6e0f8a22a3c4de287f698b9f74a45183f1b4314f66b8dfe643d33f2"
|
|
4156
4156
|
},
|
|
4157
4157
|
{
|
|
4158
4158
|
"path": "modules/copy-desk/pages.js",
|
|
@@ -4172,7 +4172,7 @@
|
|
|
4172
4172
|
{
|
|
4173
4173
|
"path": "modules/copy-desk/routes/copy-desk.js",
|
|
4174
4174
|
"mode": "0000644",
|
|
4175
|
-
"sha256": "
|
|
4175
|
+
"sha256": "175a45af668fbe7405dddfba3ea922e0021dbd2d59dcfdfb6e947351c5bfcfe8"
|
|
4176
4176
|
},
|
|
4177
4177
|
{
|
|
4178
4178
|
"path": "modules/copy-desk/tests/copy_docx.mjs",
|
|
@@ -4407,7 +4407,7 @@
|
|
|
4407
4407
|
{
|
|
4408
4408
|
"path": "modules/economy/credits.js",
|
|
4409
4409
|
"mode": "0000644",
|
|
4410
|
-
"sha256": "
|
|
4410
|
+
"sha256": "f26603fecd1771d8286fc471f9c9c51ad96774b23967811de621bbf96dd482fc"
|
|
4411
4411
|
},
|
|
4412
4412
|
{
|
|
4413
4413
|
"path": "modules/economy/idea-brightness.js",
|
|
@@ -4442,12 +4442,12 @@
|
|
|
4442
4442
|
{
|
|
4443
4443
|
"path": "modules/economy/reward-policy.js",
|
|
4444
4444
|
"mode": "0000644",
|
|
4445
|
-
"sha256": "
|
|
4445
|
+
"sha256": "39b06cfa5ae58fce68633f6b2aef74eb9421bf3346cc54a0658b0186136551cf"
|
|
4446
4446
|
},
|
|
4447
4447
|
{
|
|
4448
4448
|
"path": "modules/economy/reward.js",
|
|
4449
4449
|
"mode": "0000644",
|
|
4450
|
-
"sha256": "
|
|
4450
|
+
"sha256": "4c54857aea9bc12c59e712410b0b98124d0274dece739bfbd844f52dc6ac6880"
|
|
4451
4451
|
},
|
|
4452
4452
|
{
|
|
4453
4453
|
"path": "modules/economy/routes/cost.js",
|
|
@@ -6092,7 +6092,7 @@
|
|
|
6092
6092
|
{
|
|
6093
6093
|
"path": "modules/lifecycle/db-ship.js",
|
|
6094
6094
|
"mode": "0000644",
|
|
6095
|
-
"sha256": "
|
|
6095
|
+
"sha256": "7d43343a2db27ab3a281bb301cc2f3071698aa9a0b66b280b88fe348b7a61b16"
|
|
6096
6096
|
},
|
|
6097
6097
|
{
|
|
6098
6098
|
"path": "modules/lifecycle/db-tasks.js",
|
|
@@ -8627,12 +8627,12 @@
|
|
|
8627
8627
|
{
|
|
8628
8628
|
"path": "package-lock.json",
|
|
8629
8629
|
"mode": "0000644",
|
|
8630
|
-
"sha256": "
|
|
8630
|
+
"sha256": "dedef6af9441180d979a552db8177040fe36401288584d25a19af72bddb17785"
|
|
8631
8631
|
},
|
|
8632
8632
|
{
|
|
8633
8633
|
"path": "package.json",
|
|
8634
8634
|
"mode": "0000644",
|
|
8635
|
-
"sha256": "
|
|
8635
|
+
"sha256": "5351891c0be6644fc56740ac01c2c52d8fb7bc84b202b351cfb239f4b34dcff1"
|
|
8636
8636
|
},
|
|
8637
8637
|
{
|
|
8638
8638
|
"path": "public-docs/index.html",
|
|
@@ -8652,7 +8652,7 @@
|
|
|
8652
8652
|
{
|
|
8653
8653
|
"path": "release-notes.json",
|
|
8654
8654
|
"mode": "0000644",
|
|
8655
|
-
"sha256": "
|
|
8655
|
+
"sha256": "08071a7100ca28392f74d548016d7cb2ed0e354b7d0d2bce1a0a9c565cea4f10"
|
|
8656
8656
|
},
|
|
8657
8657
|
{
|
|
8658
8658
|
"path": "scripts/bongos-mcp.js",
|
|
@@ -10742,7 +10742,7 @@
|
|
|
10742
10742
|
{
|
|
10743
10743
|
"path": "src/module-api.js",
|
|
10744
10744
|
"mode": "0000644",
|
|
10745
|
-
"sha256": "
|
|
10745
|
+
"sha256": "45f6883c5bc140e80f36240647a5ab4ec0840a9967c504ea959f7eadb7257a1d"
|
|
10746
10746
|
},
|
|
10747
10747
|
{
|
|
10748
10748
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11069,6 +11069,11 @@
|
|
|
11069
11069
|
"mode": "0000644",
|
|
11070
11070
|
"sha256": "3337ab686f2fa4bef95d0eabcf9b134a9b919d40480750e07a500b8007ae9659"
|
|
11071
11071
|
},
|
|
11072
|
+
{
|
|
11073
|
+
"path": "tests/artist_page_credit.mjs",
|
|
11074
|
+
"mode": "0000644",
|
|
11075
|
+
"sha256": "df3266f78209e0ca45fa9cc6be2b52bc299bfb4bf93dd360196054812166e5cc"
|
|
11076
|
+
},
|
|
11072
11077
|
{
|
|
11073
11078
|
"path": "tests/asset_version.mjs",
|
|
11074
11079
|
"mode": "0000644",
|
|
@@ -141,6 +141,7 @@ An artist working in the studio runs no session, so stream 2 can never reach the
|
|
|
141
141
|
- **The applier keeps its normal build reward** (the cost-plus session reward, on land, unchanged). **The shipper's per-task estimate is not paid on a page-tweak task**, because the artist lane takes that stream, so the total is conserved in both reward modes.
|
|
142
142
|
- **Self-applied** (the artist also ran `/tweak`): one artist row plus their own session reward. Nothing is doubled.
|
|
143
143
|
- The tally (D8) reads these rows.
|
|
144
|
+
- **As built** (task 1004324, BV2.TW13): `awardArtistPageCredit` in `modules/economy/credits.js`, called from `awardLandingCredit` in `modules/lifecycle/db-ship.js` in place of the estimate on a `page-tweak` task. The idempotency guard keys on `(task_id, reason)`, one payment per round, which is stricter than "(builder, reason)" and implies it, since a round has one payee. The row must read `shipped` in the ship transaction, so a sent-back round is never paid. A round with no web claim, or a zero priced value, books nothing.
|
|
144
145
|
|
|
145
146
|
### D11. The routes, in one place
|
|
146
147
|
|
|
@@ -36,7 +36,7 @@ env override → instance config (config/branding.json) → neutral star
|
|
|
36
36
|
| `repo` | `{ owner, name }` GitHub binding. |
|
|
37
37
|
| `db.database` | DB name Bongos pool connects to when neither `DATABASE_URL` nor `PGHOST`/`PGDATABASE` is set (`src/bongos/pool.js`, ADR 0062 §5). `null` in the neutral starter = **fail loud** rather than silently target a host DB. |
|
|
38
38
|
| `currency` | `{ label, symbol }` — the reward unit label (e.g. "example"). Display only. |
|
|
39
|
-
| `reward` | `{ marginPct, usdPeg, mode }` — the per-instance reward POLICY the economy module reads ([ADR 0054](adr/0054-session-token-reward-cost-plus.md) + ADR 0146, tasks 2009/1002270): the cost-plus margin percent (default `20`), the example-per-USD peg (default `1`), and `mode` — `"cost-plus-and-estimate"` (default; pay both the per-task author estimate AND the cost-plus session reward) or `"cost-plus-only"` (suppress the per-task estimate payout; the cost-plus session reward is the sole equity reward). A committed, code-reviewed change — not a live UI knob. Omit ⇒ code defaults (20% margin, 1:1 peg, pay both streams). |
|
|
39
|
+
| `reward` | `{ marginPct, usdPeg, mode }` — the per-instance reward POLICY the economy module reads ([ADR 0054](adr/0054-session-token-reward-cost-plus.md) + ADR 0146, tasks 2009/1002270): the cost-plus margin percent (default `20`), the example-per-USD peg (default `1`), and `mode` — `"cost-plus-and-estimate"` (default; pay both the per-task author estimate AND the cost-plus session reward) or `"cost-plus-only"` (suppress the per-task estimate payout; the cost-plus session reward is the sole equity reward). Two per-craft lanes sit beside `mode` and are not derived from it: `ideatorCredit` ([ADR 0172](adr/0172-per-craft-compensation-ideator-credit-lane.md)) and `artistCredit` ([ADR 0341](adr/0341-the-page-is-the-unit-of-tweak-mode.md) D10, an approved page tweak pays its artist), each default on and withheld only by an explicit `false`. A committed, code-reviewed change — not a live UI knob. Omit ⇒ code defaults (20% margin, 1:1 peg, pay both streams, both lanes on). |
|
|
40
40
|
| `models` | `{ main, subagentDefault, subagentRoutine }` — the per-instance subagent model-allocation DEFAULT the builder-settings module reads (task 2011): the tier for the main session, subagent-default work, and routine plumbing (tiers: `haiku`/`sonnet`/`opus`/`fable`). A per-builder override resolves over this. Omit ⇒ code defaults (`opus`/`opus`/`haiku`). |
|
|
41
41
|
| `tracks` | The configurable version-track set (replaces the hardcoded `product\|internal`). |
|
|
42
42
|
| `theme` | `{ palette, mode, ui }` — palette file ref + theme mode, plus `ui`, the WEB look `themeMarkup()` splices into every served page as a plain `:root` after the page's stylesheet: `colors` (the thirteen contract tokens — **light-mode values**; each stylesheet derives its own dark tier from them at `:root[data-mode="dark"]` + its `prefers-color-scheme` twin, which outrank the pack), `fonts` (`display`, `body`, `links`), `favicon`, and `backdrop` — a `body{background:…}` rule that outranks every sheet in **both** modes, so a dual-mode world sets it to `var(--bg)` (the neutral pack does since [task 1003298](https://cloudbongos.com/builders#/task/1003298), ADR 0196) rather than a literal. The neutral values are the chrome world's; `config/design-tokens.neutral.json` must mirror `colors` + `fonts` key for key (`tests/design_tokens_sync.mjs`), and the world's derived recipes are recorded in `DESIGN.md`. The ui-design module's **style library** (`modules/ui-design/styles/`, [ADR 0219](adr/0219-a-look-is-a-branding-pack-the-style-library.md)) ships ready-made `theme.ui` blocks (looks) an instance copies into this field; every served sheet reads only these fifteen, so a look re-themes every surface with no page edit. |
|
|
@@ -2617,5 +2617,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2617
2617
|
landed since 1.19.1063 with no explicit bump. run 36441707814. (task 1002620)
|
|
2618
2618
|
1.19.1065 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2619
2619
|
landed since 1.19.1064 with no explicit bump. run 36451089716. (task 1002620)
|
|
2620
|
+
1.19.1066 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2621
|
+
landed since 1.19.1065 with no explicit bump. run 36454504693. (task 1002620)
|
|
2620
2622
|
---------------------------------------------------------------------------
|
|
2621
2623
|
```
|
|
@@ -36,8 +36,10 @@ const pages = require('./pages');
|
|
|
36
36
|
|
|
37
37
|
const TERMINAL = new Set(['shipped', 'abandoned']);
|
|
38
38
|
|
|
39
|
-
// The credit reason ADR 0341 D10 books an approved page under.
|
|
40
|
-
//
|
|
39
|
+
// The credit reason ADR 0341 D10 books an approved page under. The economy
|
|
40
|
+
// module writes it at the land (credits.js awardArtistPageCredit, task 1004324,
|
|
41
|
+
// its ARTIST_PAGE_REASON_PREFIX); restated here because a module cannot require
|
|
42
|
+
// another, and tests/artist_page_credit.mjs holds the two strings together.
|
|
41
43
|
const TALLY_REASON_PREFIX = 'tweak.page_approved:';
|
|
42
44
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
43
45
|
|
|
@@ -361,8 +361,9 @@ function pageReadsOrFail(res) {
|
|
|
361
361
|
}
|
|
362
362
|
|
|
363
363
|
// Every `tweak.page_approved:` credit row the caller has, through the reward
|
|
364
|
-
// port (credit_log is economy's table). No reward port, or no
|
|
365
|
-
//
|
|
364
|
+
// port (credit_log is economy's table). No reward port, or no approved page
|
|
365
|
+
// landed yet (economy books one row per landed round, task 1004324), reads as
|
|
366
|
+
// none: a tally of 0 is the truth then.
|
|
366
367
|
//
|
|
367
368
|
// ONE bounded query. An approved page books one row, so the cap is thousands of
|
|
368
369
|
// pages for one artist; past it the tally says `truncated` (creditLedger's
|
|
@@ -1174,6 +1174,80 @@ async function sumSessionEarnings(executor, builderId, taskIds, sessionId) {
|
|
|
1174
1174
|
return rows[0] ? rows[0].total : 0;
|
|
1175
1175
|
}
|
|
1176
1176
|
|
|
1177
|
+
// =========================================================================
|
|
1178
|
+
// The ARTIST credit lane (task 1004324 / BV2.TW13, ADR 0341 D10).
|
|
1179
|
+
// =========================================================================
|
|
1180
|
+
// Tweak Mode replaced the per-ship artist review with an approved page, and the
|
|
1181
|
+
// owner's rule is the same payouts. A review paid its PRICED value,
|
|
1182
|
+
// credits_reward x kind multiplier, as the per-task estimate, which cost-plus-only
|
|
1183
|
+
// withholds, so paid literally a studio artist would earn nothing on this
|
|
1184
|
+
// instance. So an approved page pays that priced value (30 for a feature round
|
|
1185
|
+
// at 30) on its own lane, `artistCredit`, which cost-plus-only does not touch:
|
|
1186
|
+
// the ideator lane's precedent (ADR 0172). The lifecycle calls this at the land
|
|
1187
|
+
// INSTEAD of the shipper's per-task estimate, so the total is conserved in both
|
|
1188
|
+
// modes, and the applier keeps its cost-plus session reward, which is booked
|
|
1189
|
+
// elsewhere and is untouched here.
|
|
1190
|
+
//
|
|
1191
|
+
// The reason prefix is the one copy-desk's tally already sums
|
|
1192
|
+
// (modules/copy-desk/page-status.js TALLY_REASON_PREFIX, restated there because
|
|
1193
|
+
// a module cannot require another; tests/artist_page_credit.mjs holds the two
|
|
1194
|
+
// strings together).
|
|
1195
|
+
const ARTIST_PAGE_REASON_PREFIX = 'tweak.page_approved:';
|
|
1196
|
+
const PAGE_TWEAK_SOURCE = 'page-tweak';
|
|
1197
|
+
|
|
1198
|
+
// Everything is read from the ledger inside the caller's transaction, never
|
|
1199
|
+
// taken from the caller (ADR 0185): the task's own source, status and priced
|
|
1200
|
+
// value, and the payee, the builder of the round's LATEST WEB claim (both
|
|
1201
|
+
// worktree_name and creator_session_id null), the claim the submit released.
|
|
1202
|
+
// That is the TW05 reads' artist rule (modules/lifecycle/page-tweak-reads.js),
|
|
1203
|
+
// so the changelog's artist and the paid artist are the same person.
|
|
1204
|
+
//
|
|
1205
|
+
// NEVER PAID UNLESS SHIPPED. The row must read `shipped` in this transaction, so
|
|
1206
|
+
// a round that was sent back, abandoned or is still waiting pays nothing, even
|
|
1207
|
+
// if a caller reaches this early.
|
|
1208
|
+
//
|
|
1209
|
+
// ONE PAYMENT PER ROUND. The NOT EXISTS keys on (task_id, reason), served by
|
|
1210
|
+
// idx_credit_log_task. That is stricter than ADR 0341's "(builder, reason)": a
|
|
1211
|
+
// round has one payee, so keying on the round alone also refuses a second row
|
|
1212
|
+
// should the payee ever read differently on a re-sweep, and a self-applied round
|
|
1213
|
+
// (the artist also ran /tweak) is still one artist row, never two.
|
|
1214
|
+
//
|
|
1215
|
+
// Returns { credited, reason, artistId, delta } with a reason on every path:
|
|
1216
|
+
// 'paid' · 'already_paid' · 'artist_lane_off' · 'not_found' · 'not_a_page_tweak'
|
|
1217
|
+
// · 'not_shipped' · 'no_artist' · 'zero_value'.
|
|
1218
|
+
async function awardArtistPageCredit(client, { taskId, config = rewardConfig() } = {}) {
|
|
1219
|
+
if (config && config.artistCredit === false) return { credited: 0, reason: 'artist_lane_off' };
|
|
1220
|
+
const { rows } = await client.query(
|
|
1221
|
+
`SELECT t.source, t.status, t.credits_reward, t.kind,
|
|
1222
|
+
(SELECT c.builder_id FROM claims c
|
|
1223
|
+
WHERE c.task_id = t.id AND c.worktree_name IS NULL AND c.creator_session_id IS NULL
|
|
1224
|
+
ORDER BY c.claimed_at DESC, c.id DESC
|
|
1225
|
+
LIMIT 1) AS artist_id
|
|
1226
|
+
FROM tasks t
|
|
1227
|
+
WHERE t.id = $1`,
|
|
1228
|
+
[taskId]
|
|
1229
|
+
);
|
|
1230
|
+
const t = rows[0];
|
|
1231
|
+
if (!t) return { credited: 0, reason: 'not_found' };
|
|
1232
|
+
if (t.source !== PAGE_TWEAK_SOURCE) return { credited: 0, reason: 'not_a_page_tweak' };
|
|
1233
|
+
if (t.status !== 'shipped') return { credited: 0, reason: 'not_shipped' };
|
|
1234
|
+
if (t.artist_id == null) return { credited: 0, reason: 'no_artist' };
|
|
1235
|
+
// The review's priced value, by the one multiplier rule (rounded once, there).
|
|
1236
|
+
const priced = applyKindMultiplier(t.credits_reward, t.kind);
|
|
1237
|
+
if (priced.effective <= 0) return { credited: 0, reason: 'zero_value', artistId: t.artist_id };
|
|
1238
|
+
const reason = `${ARTIST_PAGE_REASON_PREFIX}${taskId}`;
|
|
1239
|
+
const { rowCount } = await client.query(
|
|
1240
|
+
`INSERT INTO credit_log (builder_id, task_id, delta, reason, description)
|
|
1241
|
+
SELECT $1, $2, $3, $4, $5
|
|
1242
|
+
WHERE NOT EXISTS (SELECT 1 FROM credit_log WHERE task_id = $2 AND reason = $4)`,
|
|
1243
|
+
[t.artist_id, taskId, priced.effective, reason,
|
|
1244
|
+
`Approved page tweak, task #${taskId}: ${priced.description} to its artist (artist lane, ADR 0341 D10)`]
|
|
1245
|
+
);
|
|
1246
|
+
return rowCount > 0
|
|
1247
|
+
? { credited: priced.effective, reason: 'paid', artistId: t.artist_id, delta: priced.effective }
|
|
1248
|
+
: { credited: 0, reason: 'already_paid', artistId: t.artist_id, delta: priced.effective };
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1177
1251
|
// The three ideator streams (ADR 0172) each book under a reason key that EMBEDS
|
|
1178
1252
|
// the idea (and, for stream 1, the task) — that string IS the idempotency
|
|
1179
1253
|
// predicate, which is why it can't be a bare constant:
|
|
@@ -1294,4 +1368,7 @@ module.exports = {
|
|
|
1294
1368
|
// the ledger read surface (task 1003484, blocker 1000127)
|
|
1295
1369
|
creditLedger,
|
|
1296
1370
|
IDEA_STREAM_REASON_PREFIXES,
|
|
1371
|
+
// the artist lane (task 1004324 / BV2.TW13, ADR 0341 D10)
|
|
1372
|
+
awardArtistPageCredit,
|
|
1373
|
+
ARTIST_PAGE_REASON_PREFIX,
|
|
1297
1374
|
};
|
|
@@ -61,6 +61,12 @@ function rewardPolicy(rawReward) {
|
|
|
61
61
|
const perTaskCredit = mode !== REWARD_MODE_COST_PLUS_ONLY;
|
|
62
62
|
// Default-on, opt-out-only: only an explicit `false` withholds the ideator lane.
|
|
63
63
|
const ideatorCredit = r.ideatorCredit !== false;
|
|
64
|
+
// The ARTIST lane (task 1004324 / BV2.TW13, ADR 0341 D10): an approved page
|
|
65
|
+
// pays its artist on its own lane, the ideator lane's precedent exactly. Not
|
|
66
|
+
// derived from `mode`, so cost-plus-only does not withhold it: a studio artist
|
|
67
|
+
// runs no session, so the cost-plus reward can never reach them. Default-on,
|
|
68
|
+
// opt-out-only, the same rule as ideatorCredit.
|
|
69
|
+
const artistCredit = r.artistCredit !== false;
|
|
64
70
|
// Validated to [0, 1): see IDEA_DESCENT_DECAY above — a rate of 1 or more does
|
|
65
71
|
// not converge, and a negative one would pay a descent backwards.
|
|
66
72
|
// ABSENT IS THE DEFAULT, NOT ZERO — and Number(null) is 0, which is a legal
|
|
@@ -71,7 +77,7 @@ function rewardPolicy(rawReward) {
|
|
|
71
77
|
const ideaDescentDecay = Number.isFinite(rawDecay) && rawDecay >= 0 && rawDecay < 1
|
|
72
78
|
? rawDecay
|
|
73
79
|
: IDEA_DESCENT_DECAY;
|
|
74
|
-
return { marginPct, usdPeg, mode, perTaskCredit, ideatorCredit, ideaDescentDecay };
|
|
80
|
+
return { marginPct, usdPeg, mode, perTaskCredit, ideatorCredit, artistCredit, ideaDescentDecay };
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
module.exports = {
|
|
@@ -40,6 +40,10 @@ module.exports = {
|
|
|
40
40
|
// `client` (docs/specs/board-room-pass-event-contract.md) — the port is how
|
|
41
41
|
// money crosses the wall; the `board.item.passed` event never carries a payment.
|
|
42
42
|
awardIdeaGovernancePassCredit: (...a) => credits.awardIdeaGovernancePassCredit(...a),
|
|
43
|
+
// BV2.TW13 / ADR 0341 D10 — the artist lane: an approved page, at its land,
|
|
44
|
+
// pays its artist the review's priced value. The lifecycle's landing hook calls
|
|
45
|
+
// it INSTEAD of the per-task estimate on a page tweak; the payee is read here.
|
|
46
|
+
awardArtistPageCredit: (...a) => credits.awardArtistPageCredit(...a),
|
|
43
47
|
awardNetNegativeBonus: (...a) => credits.awardNetNegativeBonus(...a),
|
|
44
48
|
awardSessionTokenReward: (...a) => credits.awardSessionTokenReward(...a),
|
|
45
49
|
// gamification hooks the lifecycle fires at claim/ship
|
|
@@ -18,6 +18,7 @@ const doneWhen = require('./done-when.js');
|
|
|
18
18
|
const { THETES_GRADUATION_THRESHOLD, maybeAutoGraduateToThetes, foldWorktreeName } = require('./db-rank-authz.js');
|
|
19
19
|
const { mostRecentClaimHolderId, sealSessionLog, staleClaimHourPredicate } = require('./db-shared.js');
|
|
20
20
|
const { LIVE_STATUSES } = require('./dead-deps.js');
|
|
21
|
+
const { isPageTweak } = require('./page-tweak-hold.js');
|
|
21
22
|
|
|
22
23
|
// shouldRefuseClaimResolve — may THIS caller resolve THIS claim? (task 1003767)
|
|
23
24
|
//
|
|
@@ -346,8 +347,15 @@ async function resolveClaim({ claimId, builderId, outcome, notesMd, valueSummary
|
|
|
346
347
|
// row, re-landing books a FRESH 'task.shipped' row — the reward is fully
|
|
347
348
|
// re-earnable, exactly as the ADR promises. Caller passes its ship txn client
|
|
348
349
|
// so the credit commits atomically with the status flip.
|
|
349
|
-
async function awardLandingCredit(client, { taskId, creditedBuilderId, creditsReward, kind }) {
|
|
350
|
+
async function awardLandingCredit(client, { taskId, creditedBuilderId, creditsReward, kind, source = null }) {
|
|
350
351
|
const reward = seams.resolveOptional('reward');
|
|
352
|
+
// ADR 0341 D10 (task 1004324): a page tweak's per-task stream is its ARTIST's, on the
|
|
353
|
+
// artist lane, in place of the shipper's estimate (economy reads the payee); the
|
|
354
|
+
// applier keeps its cost-plus session reward, which is booked elsewhere.
|
|
355
|
+
if (reward && isPageTweak({ source })) {
|
|
356
|
+
const artist = reward.awardArtistPageCredit ? await reward.awardArtistPageCredit(client, { taskId }) : null;
|
|
357
|
+
return { creditsAwarded: 0, ideaBonus: null, artist };
|
|
358
|
+
}
|
|
351
359
|
if (!reward || creditedBuilderId == null || !(Number(creditsReward) > 0)) {
|
|
352
360
|
return { creditsAwarded: 0, ideaBonus: null };
|
|
353
361
|
}
|
|
@@ -510,7 +518,7 @@ async function shipTask({ taskId, builderId, landAssurance }, deps = {}) {
|
|
|
510
518
|
// under NODE_ENV==='test', so a regression test can drive the txn with a
|
|
511
519
|
// SQL-aware fakeClient. Production callers omit deps → module pool.
|
|
512
520
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
513
|
-
const { shipRow, graduationBuilder, rankGraduation, actualShipperId, closedClaimCount, creditedBuilderId, creditsAwarded, ideaBonus, justUnlocked } = await withTx(async (client) => {
|
|
521
|
+
const { shipRow, graduationBuilder, rankGraduation, actualShipperId, closedClaimCount, creditedBuilderId, creditsAwarded, ideaBonus, artistCredit, justUnlocked } = await withTx(async (client) => {
|
|
514
522
|
const { rows } = await client.query(
|
|
515
523
|
`UPDATE tasks
|
|
516
524
|
SET status = 'shipped',
|
|
@@ -521,7 +529,7 @@ async function shipTask({ taskId, builderId, landAssurance }, deps = {}) {
|
|
|
521
529
|
needs_rebase_at = NULL,
|
|
522
530
|
updated_at = now()
|
|
523
531
|
WHERE id = $1 AND status = 'confirmed'
|
|
524
|
-
RETURNING id, title, status, shipped_at, shipped_by, security_sensitive, value_summary, credits_reward, kind`,
|
|
532
|
+
RETURNING id, title, status, shipped_at, shipped_by, security_sensitive, value_summary, credits_reward, kind, source`,
|
|
525
533
|
[taskId, builderId]
|
|
526
534
|
);
|
|
527
535
|
if (rows.length === 0) {
|
|
@@ -626,6 +634,7 @@ async function shipTask({ taskId, builderId, landAssurance }, deps = {}) {
|
|
|
626
634
|
creditedBuilderId,
|
|
627
635
|
creditsReward: rows[0].credits_reward,
|
|
628
636
|
kind: rows[0].kind,
|
|
637
|
+
source: rows[0].source,
|
|
629
638
|
});
|
|
630
639
|
// task 1679 pattern: state-based achievement reconcile now that the credit
|
|
631
640
|
// (above) + the ship count are reflected in this txn. SAVEPOINT-wrapped so
|
|
@@ -647,7 +656,7 @@ async function shipTask({ taskId, builderId, landAssurance }, deps = {}) {
|
|
|
647
656
|
return {
|
|
648
657
|
shipRow: rows[0], graduationBuilder, rankGraduation, actualShipperId,
|
|
649
658
|
closedClaimCount: closedClaims.length,
|
|
650
|
-
creditedBuilderId, creditsAwarded: landing.creditsAwarded, ideaBonus: landing.ideaBonus, justUnlocked,
|
|
659
|
+
creditedBuilderId, creditsAwarded: landing.creditsAwarded, ideaBonus: landing.ideaBonus, artistCredit: landing.artist || null, justUnlocked,
|
|
651
660
|
};
|
|
652
661
|
}, { pool: activePool });
|
|
653
662
|
if (closedClaimCount > 0) {
|
|
@@ -677,7 +686,7 @@ async function shipTask({ taskId, builderId, landAssurance }, deps = {}) {
|
|
|
677
686
|
// ADR 0120: the per-task reward that landed on THIS ship (0 on a re-ship /
|
|
678
687
|
// reconciler re-sweep — idempotent). The ship route surfaces it so the
|
|
679
688
|
// builder sees "credits landed" at ship, not at confirm.
|
|
680
|
-
creditedBuilderId, creditsAwarded, ideaBonus, justUnlocked,
|
|
689
|
+
creditedBuilderId, creditsAwarded, ideaBonus, artistCredit, justUnlocked,
|
|
681
690
|
};
|
|
682
691
|
}
|
|
683
692
|
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1066",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.1066",
|
|
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.
|
|
3
|
+
"version": "1.19.1066",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -7691,5 +7691,11 @@
|
|
|
7691
7691
|
"id": "1004323",
|
|
7692
7692
|
"text": "An artist can now approve a tweaked page from the studio. A new Approval queue button opens a glass panel that shows the applied page as real pictures, before and after, on desktop and phone, in light and dark, with the change"
|
|
7693
7693
|
}
|
|
7694
|
+
],
|
|
7695
|
+
"1.19.1066": [
|
|
7696
|
+
{
|
|
7697
|
+
"id": "1004324",
|
|
7698
|
+
"text": "When an artist's page rewrite is approved and goes live, the artist is now paid 30 credits for it, the same value an artist review used to carry. The payment lands at the moment the page ships, never at approval, and a page th"
|
|
7699
|
+
}
|
|
7694
7700
|
]
|
|
7695
7701
|
}
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1066'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// tests/artist_page_credit.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1004324 / BV2.TW13, ADR 0341 D10: an approved page pays its ARTIST at
|
|
4
|
+
// the land, as the artist reviews it replaced paid.
|
|
5
|
+
//
|
|
6
|
+
// The contract this pins, through the REAL db.shipTask and the REAL economy
|
|
7
|
+
// writer over a small stateful ledger (tasks, claims, credit_log):
|
|
8
|
+
// * a shipped page tweak books ONE credit_log row, reason
|
|
9
|
+
// tweak.page_approved:<task id>, to the builder of the round's latest WEB
|
|
10
|
+
// claim, worth credits_reward x KIND_MULTIPLIERS[kind] (30 for a feature
|
|
11
|
+
// round at 30);
|
|
12
|
+
// * the shipper's per-task estimate (task.shipped) is NOT paid on a page
|
|
13
|
+
// tweak, so the per-task stream's total is the same as an ordinary ship's;
|
|
14
|
+
// * a self-applied round (the artist also ran /tweak) is one artist row;
|
|
15
|
+
// * rounding is the one multiplier rule, once;
|
|
16
|
+
// * a re-ship or a reconciler re-sweep books nothing a second time;
|
|
17
|
+
// * a sent-back round is never paid, and a re-applied round pays its artist,
|
|
18
|
+
// never either applier;
|
|
19
|
+
// * the lane is not withheld by cost-plus-only, and only an explicit
|
|
20
|
+
// artistCredit:false turns it off;
|
|
21
|
+
// * the reason prefix is the one the studio tally sums, and the tally reads
|
|
22
|
+
// the booked row as 30 credits.
|
|
23
|
+
//
|
|
24
|
+
// Run: node tests/artist_page_credit.mjs
|
|
25
|
+
|
|
26
|
+
import { strict as assert } from 'node:assert';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
|
|
29
|
+
|
|
30
|
+
process.env.NODE_ENV = 'test';
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const achievements = require('../modules/economy/achievements.js');
|
|
34
|
+
achievements.evaluateAfterShip = async () => [];
|
|
35
|
+
achievements.evaluateAfterCreditAward = async () => [];
|
|
36
|
+
achievements.reconcileBuilderAchievements = async () => [];
|
|
37
|
+
const seams = require('../src/module-seams.js');
|
|
38
|
+
if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
|
|
39
|
+
const credits = require('../modules/economy/credits.js');
|
|
40
|
+
const db = require('../modules/lifecycle/db.js');
|
|
41
|
+
const pageStatus = require('../modules/copy-desk/page-status.js');
|
|
42
|
+
|
|
43
|
+
const { test, summary } = makeRunner();
|
|
44
|
+
|
|
45
|
+
const ARTIST = '41';
|
|
46
|
+
const APPLIER = '42';
|
|
47
|
+
const APPLIER_2 = '43';
|
|
48
|
+
const OTHER_ARTIST = '44';
|
|
49
|
+
const RECONCILER = '999';
|
|
50
|
+
const LAND_PROOF = { kind: 'proof', reason: 'test fixture: land proven' };
|
|
51
|
+
|
|
52
|
+
// The claims a round collects, in order. A web claim has no worktree and no
|
|
53
|
+
// session; the applier's CLI claim has a worktree.
|
|
54
|
+
const web = (builder, at) => ({ builder_id: builder, claimed_at: at, worktree_name: null, creator_session_id: null });
|
|
55
|
+
const cli = (builder, at) => ({ builder_id: builder, claimed_at: at, worktree_name: `tw-${builder}-${at}`, creator_session_id: `s-${builder}` });
|
|
56
|
+
|
|
57
|
+
// A stateful ledger for one task. It answers the statements shipTask and the
|
|
58
|
+
// artist writer send; credit_log is a real list, so the NOT EXISTS guards are
|
|
59
|
+
// evaluated against what was actually booked.
|
|
60
|
+
function ledger({ source = 'page-tweak', status = 'confirmed', creditsReward = 30, kind = 'feature', claims = [] } = {}) {
|
|
61
|
+
const state = {
|
|
62
|
+
task: { id: 7001, title: 'Tweak: Home (landing:index)', source, status, credits_reward: creditsReward, kind, shipped_by: null },
|
|
63
|
+
claims: claims.map((c, i) => ({ id: 9000 + i, released_at: 'earlier', ...c })),
|
|
64
|
+
credit_log: [],
|
|
65
|
+
};
|
|
66
|
+
const latest = (list) => list.slice().sort((a, b) => (a.claimed_at - b.claimed_at) || (a.id - b.id)).slice(-1)[0] || null;
|
|
67
|
+
const pool = makeSqlAwareClient((s, p) => {
|
|
68
|
+
const t = state.task;
|
|
69
|
+
if (/^UPDATE tasks SET status = 'shipped'/.test(s)) {
|
|
70
|
+
if (t.status !== 'confirmed') return { rows: [] };
|
|
71
|
+
t.status = 'shipped';
|
|
72
|
+
t.shipped_by = t.shipped_by ?? p[1];
|
|
73
|
+
return { rows: [{ id: t.id, title: t.title, status: 'shipped', shipped_at: new Date().toISOString(), shipped_by: t.shipped_by,
|
|
74
|
+
security_sensitive: false, value_summary: null, credits_reward: t.credits_reward, kind: t.kind, source: t.source }] };
|
|
75
|
+
}
|
|
76
|
+
if (/^UPDATE claims/.test(s)) return { rows: [] };
|
|
77
|
+
if (/^SELECT builder_id FROM claims WHERE task_id = \$1/.test(s)) {
|
|
78
|
+
const c = latest(state.claims);
|
|
79
|
+
return { rows: c ? [{ builder_id: c.builder_id }] : [] };
|
|
80
|
+
}
|
|
81
|
+
if (/^SELECT 1 FROM credit_log WHERE task_id = \$1 AND reason = 'task\.shipped'/.test(s)) {
|
|
82
|
+
return { rows: state.credit_log.filter((r) => r.task_id === p[0] && r.reason === 'task.shipped').map(() => ({ one: 1 })) };
|
|
83
|
+
}
|
|
84
|
+
if (/^SELECT t\.source, t\.status, t\.credits_reward, t\.kind/.test(s)) {
|
|
85
|
+
if (p[0] !== t.id) return { rows: [] };
|
|
86
|
+
assert.match(s, /c\.worktree_name IS NULL AND c\.creator_session_id IS NULL/, 'the payee is read from WEB claims only');
|
|
87
|
+
const w = latest(state.claims.filter((c) => c.worktree_name == null && c.creator_session_id == null));
|
|
88
|
+
return { rows: [{ source: t.source, status: t.status, credits_reward: t.credits_reward, kind: t.kind, artist_id: w ? w.builder_id : null }] };
|
|
89
|
+
}
|
|
90
|
+
if (/^INSERT INTO credit_log/.test(s)) {
|
|
91
|
+
const [builder_id, task_id, delta, reason] = p;
|
|
92
|
+
if (/WHERE NOT EXISTS \(SELECT 1 FROM credit_log WHERE task_id = \$2 AND reason = \$4\)/.test(s)
|
|
93
|
+
&& state.credit_log.some((r) => r.task_id === task_id && r.reason === reason)) return { rows: [], rowCount: 0 };
|
|
94
|
+
if (/WHERE NOT EXISTS \(SELECT 1 FROM credit_log WHERE builder_id = \$1 AND reason = \$4\)/.test(s)
|
|
95
|
+
&& state.credit_log.some((r) => r.builder_id === builder_id && r.reason === reason)) return { rows: [], rowCount: 0 };
|
|
96
|
+
state.credit_log.push({ builder_id, task_id, delta, reason, recorded_at: new Date().toISOString() });
|
|
97
|
+
return { rows: [], rowCount: 1 };
|
|
98
|
+
}
|
|
99
|
+
return { rows: [] };
|
|
100
|
+
});
|
|
101
|
+
return { pool, state };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ship = (pool) => db.shipTask({ taskId: 7001, builderId: RECONCILER, landAssurance: LAND_PROOF }, { pool });
|
|
105
|
+
const sent = (pool) => pool._client.queries.map((q) => String(q.sql).replace(/\s+/g, ' ').trim());
|
|
106
|
+
const perTaskRows = (state) => state.credit_log.filter((r) => r.reason === 'task.shipped' || r.reason.startsWith('tweak.page_approved:'));
|
|
107
|
+
|
|
108
|
+
// =========================================================================
|
|
109
|
+
await test('a shipped page tweak pays its artist 30, on tweak.page_approved:<id>, and the shipper no estimate', async () => {
|
|
110
|
+
const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
|
|
111
|
+
const out = await ship(pool);
|
|
112
|
+
assert.equal(out.status, 'shipped');
|
|
113
|
+
assert.equal(out.creditsAwarded, 0, 'the shipper is paid no per-task estimate on a page tweak');
|
|
114
|
+
assert.equal(String(out.creditedBuilderId), APPLIER, 'the applier is still the ship-credited builder (achievements, session reward)');
|
|
115
|
+
assert.deepEqual(state.credit_log.map((r) => [String(r.builder_id), r.task_id, r.delta, r.reason]),
|
|
116
|
+
[[ARTIST, 7001, 30, 'tweak.page_approved:7001']], 'exactly one row: the artist, the priced value, the tally reason');
|
|
117
|
+
assert.equal(out.artistCredit.reason, 'paid');
|
|
118
|
+
assert.equal(out.artistCredit.credited, 30);
|
|
119
|
+
assert.ok(!sent(pool).some((q) => /'task\.shipped'/.test(q)), 'the estimate path is never entered for a page tweak');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await test('self-applied: the artist also ran /tweak, and is paid one artist row, nothing doubled', async () => {
|
|
123
|
+
const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(ARTIST, 2)] });
|
|
124
|
+
const out = await ship(pool);
|
|
125
|
+
assert.equal(String(out.creditedBuilderId), ARTIST);
|
|
126
|
+
assert.equal(state.credit_log.length, 1, 'one row, not an artist row plus an estimate row');
|
|
127
|
+
assert.equal(String(state.credit_log[0].builder_id), ARTIST);
|
|
128
|
+
assert.equal(state.credit_log[0].delta, 30);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await test('rounding: the priced value is credits_reward x the kind multiplier, rounded once', async () => {
|
|
132
|
+
for (const [reward, kind, want] of [[30, 'feature', 30], [25, 'bug', 30], [7, 'refactor', 6], [5, 'spike', 6], [3, 'cleanup', 2], [11, 'mystery', 11]]) {
|
|
133
|
+
const { pool, state } = ledger({ creditsReward: reward, kind, claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
|
|
134
|
+
await ship(pool);
|
|
135
|
+
assert.equal(state.credit_log.length, 1, `${reward} x ${kind}: one row`);
|
|
136
|
+
assert.equal(state.credit_log[0].delta, want, `${reward} x ${kind} pays ${want}`);
|
|
137
|
+
assert.equal(state.credit_log[0].delta, credits.applyKindMultiplier(reward, kind).effective, 'the same rule as the estimate');
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await test('a zero-reward round books nothing (no zero-value row that reads like a payment)', async () => {
|
|
142
|
+
const { pool, state } = ledger({ creditsReward: 0, claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
|
|
143
|
+
const out = await ship(pool);
|
|
144
|
+
assert.equal(state.credit_log.length, 0);
|
|
145
|
+
assert.equal(out.artistCredit.reason, 'zero_value');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await test('the per-task stream total is conserved: a page tweak pays the artist what the ordinary ship paid the shipper', async () => {
|
|
149
|
+
for (const [reward, kind] of [[30, 'feature'], [25, 'bug'], [7, 'refactor']]) {
|
|
150
|
+
const ordinary = ledger({ source: null, creditsReward: reward, kind, claims: [cli(APPLIER, 2)] });
|
|
151
|
+
await ship(ordinary.pool);
|
|
152
|
+
const tweak = ledger({ creditsReward: reward, kind, claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
|
|
153
|
+
await ship(tweak.pool);
|
|
154
|
+
const sum = (st) => perTaskRows(st).reduce((a, r) => a + r.delta, 0);
|
|
155
|
+
const ordinaryRows = perTaskRows(ordinary.state);
|
|
156
|
+
// An instance in cost-plus-only books no ordinary estimate at all; the
|
|
157
|
+
// equality then holds against what the estimate WOULD have been.
|
|
158
|
+
const estimate = ordinaryRows.length ? sum(ordinary.state) : credits.applyKindMultiplier(reward, kind).effective;
|
|
159
|
+
assert.equal(sum(tweak.state), estimate, `${reward} x ${kind}: the same total, moved to the artist`);
|
|
160
|
+
assert.ok(!perTaskRows(tweak.state).some((r) => String(r.builder_id) === APPLIER), 'the applier gets no per-task row');
|
|
161
|
+
assert.ok(!sent(ordinary.pool).some((q) => /^SELECT t\.source, t\.status/.test(q)), 'an ordinary ship never asks the artist lane');
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await test('idempotent: a re-ship or a reconciler re-sweep books nothing a second time', async () => {
|
|
166
|
+
const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
|
|
167
|
+
await ship(pool);
|
|
168
|
+
// The shipped flip is terminal, so a second shipTask throws before paying.
|
|
169
|
+
await assert.rejects(() => ship(pool), (e) => e.code === 'TASK_NOT_CONFIRMED');
|
|
170
|
+
// And the writer itself, asked again inside some later transaction, refuses.
|
|
171
|
+
const again = await credits.awardArtistPageCredit(pool._client, { taskId: 7001, config: credits.rewardPolicy({}) });
|
|
172
|
+
assert.equal(again.reason, 'already_paid');
|
|
173
|
+
assert.equal(again.credited, 0);
|
|
174
|
+
assert.equal(state.credit_log.length, 1, 'still one row');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await test('one payment per ROUND: a payee that reads differently on a re-sweep is still refused', async () => {
|
|
178
|
+
const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
|
|
179
|
+
await ship(pool);
|
|
180
|
+
state.claims.push({ id: 9100, released_at: 'later', ...web(OTHER_ARTIST, 3) });
|
|
181
|
+
const again = await credits.awardArtistPageCredit(pool._client, { taskId: 7001, config: credits.rewardPolicy({}) });
|
|
182
|
+
assert.equal(again.reason, 'already_paid', 'keyed on (task, reason), not (builder, reason)');
|
|
183
|
+
assert.equal(state.credit_log.length, 1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await test('a sent-back round is never paid: it sits at ready, its ship is refused, and the writer refuses too', async () => {
|
|
187
|
+
const { pool, state } = ledger({ status: 'ready', claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
|
|
188
|
+
await assert.rejects(() => ship(pool), (e) => e.code === 'TASK_NOT_CONFIRMED');
|
|
189
|
+
const direct = await credits.awardArtistPageCredit(pool._client, { taskId: 7001, config: credits.rewardPolicy({}) });
|
|
190
|
+
assert.equal(direct.reason, 'not_shipped', 'a round must read shipped in the transaction to be paid');
|
|
191
|
+
for (const s of ['completed', 'confirmed', 'active', 'abandoned']) {
|
|
192
|
+
state.task.status = s;
|
|
193
|
+
const r = await credits.awardArtistPageCredit(pool._client, { taskId: 7001, config: credits.rewardPolicy({}) });
|
|
194
|
+
assert.equal(r.reason, 'not_shipped', `${s} is not paid`);
|
|
195
|
+
}
|
|
196
|
+
assert.equal(state.credit_log.length, 0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
await test('sent back, re-applied by someone else, approved and landed: the artist is paid once, neither applier', async () => {
|
|
200
|
+
// web (artist writes) -> CLI (applier 1, sent back, released) -> CLI (applier 2)
|
|
201
|
+
const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(APPLIER, 2), cli(APPLIER_2, 3)] });
|
|
202
|
+
const out = await ship(pool);
|
|
203
|
+
assert.equal(String(out.creditedBuilderId), APPLIER_2, 'the landing applier is the ship-credited builder');
|
|
204
|
+
assert.deepEqual(state.credit_log.map((r) => [String(r.builder_id), r.delta]), [[ARTIST, 30]]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await test('the payee is the LATEST web claim: an artist who resumed the draft after a send-back is the one paid', async () => {
|
|
208
|
+
const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(APPLIER, 2), web(OTHER_ARTIST, 3), cli(APPLIER, 4)] });
|
|
209
|
+
await ship(pool);
|
|
210
|
+
assert.deepEqual(state.credit_log.map((r) => String(r.builder_id)), [OTHER_ARTIST],
|
|
211
|
+
'the TW05 reads name the same builder as the round\'s artist');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await test('a page tweak with no web claim pays nobody rather than guessing', async () => {
|
|
215
|
+
const { pool, state } = ledger({ claims: [cli(APPLIER, 2)] });
|
|
216
|
+
const out = await ship(pool);
|
|
217
|
+
assert.equal(out.artistCredit.reason, 'no_artist');
|
|
218
|
+
assert.equal(state.credit_log.length, 0, 'and the applier is not paid the estimate in its place');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await test('the writer reads the task itself: an ordinary task handed to it is refused', async () => {
|
|
222
|
+
const { pool, state } = ledger({ source: 'cascade', status: 'shipped', claims: [web(ARTIST, 1)] });
|
|
223
|
+
const r = await credits.awardArtistPageCredit(pool._client, { taskId: 7001, config: credits.rewardPolicy({}) });
|
|
224
|
+
assert.equal(r.reason, 'not_a_page_tweak');
|
|
225
|
+
const missing = await credits.awardArtistPageCredit(pool._client, { taskId: 1, config: credits.rewardPolicy({}) });
|
|
226
|
+
assert.equal(missing.reason, 'not_found');
|
|
227
|
+
assert.equal(state.credit_log.length, 0);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await test('the artist lane: cost-plus-only does not withhold it; only an explicit artistCredit:false does', async () => {
|
|
231
|
+
const p = credits.rewardPolicy({ mode: 'cost-plus-only' });
|
|
232
|
+
assert.equal(p.perTaskCredit, false);
|
|
233
|
+
assert.equal(p.artistCredit, true, 'a different craft\'s pay, not the builder estimate');
|
|
234
|
+
assert.equal(credits.rewardPolicy({}).artistCredit, true, 'absent -> on');
|
|
235
|
+
for (const v of [true, 1, 'yes', undefined, null]) assert.equal(credits.rewardPolicy({ artistCredit: v }).artistCredit, true);
|
|
236
|
+
assert.equal(credits.rewardPolicy({ artistCredit: false }).artistCredit, false, 'explicit opt-out');
|
|
237
|
+
|
|
238
|
+
const { pool, state } = ledger({ status: 'shipped', claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
|
|
239
|
+
const paid = await credits.awardArtistPageCredit(pool._client, { taskId: 7001, config: p });
|
|
240
|
+
assert.equal(paid.credited, 30, 'paid under cost-plus-only');
|
|
241
|
+
const off = ledger({ status: 'shipped', claims: [web(ARTIST, 1)] });
|
|
242
|
+
const r = await credits.awardArtistPageCredit(off.pool._client, { taskId: 7001, config: credits.rewardPolicy({ artistCredit: false }) });
|
|
243
|
+
assert.equal(r.reason, 'artist_lane_off');
|
|
244
|
+
assert.equal(off.state.credit_log.length, 0);
|
|
245
|
+
assert.equal(state.credit_log.length, 1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await test('the booked row is what the studio tally sums: same prefix, and it reads as 30 credits', async () => {
|
|
249
|
+
assert.equal(credits.ARTIST_PAGE_REASON_PREFIX, pageStatus.TALLY_REASON_PREFIX,
|
|
250
|
+
'economy writes and copy-desk reads the same prefix (restated in each, since a module cannot require another)');
|
|
251
|
+
const { pool, state } = ledger({ claims: [web(ARTIST, 1), cli(APPLIER, 2)] });
|
|
252
|
+
await ship(pool);
|
|
253
|
+
const tally = pageStatus.composeTally({ builderId: ARTIST, tasks: [], creditRows: state.credit_log, now: new Date() });
|
|
254
|
+
assert.equal(tally.credits, 30);
|
|
255
|
+
assert.equal(tally.this_week, 30);
|
|
256
|
+
const applierTally = pageStatus.composeTally({ builderId: APPLIER, tasks: [], creditRows: state.credit_log.filter((r) => String(r.builder_id) === APPLIER), now: new Date() });
|
|
257
|
+
assert.equal(applierTally.credits, 0);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
await test('the reward port exposes the writer, delegated so a stub bites at call time', async () => {
|
|
261
|
+
const port = require('../modules/economy/reward.js');
|
|
262
|
+
assert.equal(typeof port.awardArtistPageCredit, 'function');
|
|
263
|
+
const real = credits.awardArtistPageCredit;
|
|
264
|
+
credits.awardArtistPageCredit = async () => ({ credited: 0, reason: 'stubbed' });
|
|
265
|
+
try {
|
|
266
|
+
assert.equal((await port.awardArtistPageCredit({}, { taskId: 1 })).reason, 'stubbed');
|
|
267
|
+
} finally {
|
|
268
|
+
credits.awardArtistPageCredit = real;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
summary();
|