@bongos/core 1.19.154 → 1.19.156

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.154",
6
- "core_contract": "1.19.154",
7
- "source_commit": "fbe11785041864be4c61ec8bb98507574cdd5f10",
5
+ "core_version": "1.19.156",
6
+ "core_contract": "1.19.156",
7
+ "source_commit": "3eb459c447a131f66f5d8297ca621cca2bee58a2",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-16T15:55:19.456Z",
9
+ "built_at": "2026-08-16T16:14:04.323Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 304,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1254,
14
+ "functional_verbatim": 1256,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1579,
20
- "tree_sha256": "b118dbf9bacb9695248f0821386defeac7d1498742efe7e907383359eb1f070b",
19
+ "file_count": 1581,
20
+ "tree_sha256": "482b7fbbf94668a5a66dfbbf5f2450699c1670c04b8199d6bf3529b0853cfe31",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -382,7 +382,7 @@
382
382
  {
383
383
  "path": "config/branding.neutral.json",
384
384
  "mode": "0000644",
385
- "sha256": "dc69c5c9e9041da9cd3c297c3e7c81c8311f42952524297d9f228cc1751af3ea"
385
+ "sha256": "c3d785e319252965f8b5f338908d1dd66bd56985dafacb5d2ab8e5095d2515b3"
386
386
  },
387
387
  {
388
388
  "path": "config/design-tokens.neutral.json",
@@ -2597,7 +2597,7 @@
2597
2597
  {
2598
2598
  "path": "modules/dev-box/app/src/brand.generated.json",
2599
2599
  "mode": "0000644",
2600
- "sha256": "a230bd4095c23b5f3fcb41f9f691398ca2560a820dd9d01346fee7aec5f07cf9"
2600
+ "sha256": "516593196fc4d75a44325e478690485d91f1d293387a541df571b4bccf6e3360"
2601
2601
  },
2602
2602
  {
2603
2603
  "path": "modules/dev-box/app/src/brand.js",
@@ -4107,7 +4107,7 @@
4107
4107
  {
4108
4108
  "path": "modules/sessions/db.js",
4109
4109
  "mode": "0000644",
4110
- "sha256": "fde41184b9f82f51dc5fff5df081d3435d296c3132fb619183996c250d621692"
4110
+ "sha256": "65cb78d4a3e0507df5d2b37ac99cdb3d762b98de5c34996f64c6de7ad38dc71a"
4111
4111
  },
4112
4112
  {
4113
4113
  "path": "modules/sessions/module.json",
@@ -4117,7 +4117,7 @@
4117
4117
  {
4118
4118
  "path": "modules/sessions/pollers/session-reward-reconcile.js",
4119
4119
  "mode": "0000644",
4120
- "sha256": "8d08704aa90f14c8f1a653bc7dc252ddca983dc309f7fa0e190070878a3856e3"
4120
+ "sha256": "32942d1169776ff1b2a32b85269c285f52167a6f3a7add7e38f74f108a9f3f42"
4121
4121
  },
4122
4122
  {
4123
4123
  "path": "modules/sessions/pulse.js",
@@ -4187,12 +4187,12 @@
4187
4187
  {
4188
4188
  "path": "package-lock.json",
4189
4189
  "mode": "0000644",
4190
- "sha256": "9f78c537cd7c7914c01ae8fd732fa039c2c749e9ee7f4cf3395d6ce71d0070c8"
4190
+ "sha256": "288b7b3c867bd31f3753a058d01234d02a39d422cc9196569620b3609ca67ba8"
4191
4191
  },
4192
4192
  {
4193
4193
  "path": "package.json",
4194
4194
  "mode": "0000644",
4195
- "sha256": "d5a00856ea454cdbf92b96b9a2d536272b3dd0481cdf8ecdb92c6e5bff9ec6eb"
4195
+ "sha256": "2892b26243a2c662e0e772a9f8a2a3e7b1ae63bcc046f1d797ac2cab597d7f7e"
4196
4196
  },
4197
4197
  {
4198
4198
  "path": "public-docs/index.html",
@@ -4434,6 +4434,11 @@
4434
4434
  "mode": "0000644",
4435
4435
  "sha256": "611050e8599be5791e89f9711b76ef6efc5b1e8bd703e2badc6a6418a9ed432b"
4436
4436
  },
4437
+ {
4438
+ "path": "public-landing/assets/verro-medallion.png",
4439
+ "mode": "0000644",
4440
+ "sha256": "f374d042a0428f956212a11ca9e86870232ab46af4010bd710a15affd374ab45"
4441
+ },
4437
4442
  {
4438
4443
  "path": "public-landing/buddha.png",
4439
4444
  "mode": "0000644",
@@ -4442,7 +4447,7 @@
4442
4447
  {
4443
4448
  "path": "public-landing/index.html",
4444
4449
  "mode": "0000644",
4445
- "sha256": "f011cbcc82b95284f00dae9aa63600ea664f9edc5c7b79c533501c2c0c54495c"
4450
+ "sha256": "ab299596b6c514ac044b2471330026e1516f6878e02b9045afae0ddc1f5fbfd9"
4446
4451
  },
4447
4452
  {
4448
4453
  "path": "public-landing/projects.html",
@@ -5782,7 +5787,7 @@
5782
5787
  {
5783
5788
  "path": "src/module-api.js",
5784
5789
  "mode": "0000644",
5785
- "sha256": "0ab78138a36bd9d516a9dd8e3fed336297b4c87387fa6a2254811d5286d938ab"
5790
+ "sha256": "7e9d6b73af3f2c1ebb9aa216c63f0049ce892c85297ff2d5f00cc02aa9e6a1cb"
5786
5791
  },
5787
5792
  {
5788
5793
  "path": "src/module-loader/catalog.js",
@@ -6262,7 +6267,7 @@
6262
6267
  {
6263
6268
  "path": "tests/cloud_bongos_pack.mjs",
6264
6269
  "mode": "0000644",
6265
- "sha256": "087d0379a559ebb858e8e42458754cfb93720a5222e5f85fb81995851ce811be"
6270
+ "sha256": "9f225b5d4db7e73fbd35bea68ab6c8c64b72bac79daa7be0219e796dc758376a"
6266
6271
  },
6267
6272
  {
6268
6273
  "path": "tests/conductor.mjs",
@@ -6967,7 +6972,7 @@
6967
6972
  {
6968
6973
  "path": "tests/landing_page.mjs",
6969
6974
  "mode": "0000644",
6970
- "sha256": "89a9d21ee1c5b00ee09de2d888873fa13c1b2b6d15ab05a6bfeb7e7a5d016a03"
6975
+ "sha256": "2efddbab276f2ef38dbcf09ef6b76d4c1ff58f68c8b11a8803c7a27b605b385f"
6971
6976
  },
6972
6977
  {
6973
6978
  "path": "tests/lib_sh_resolution.mjs",
@@ -7292,7 +7297,7 @@
7292
7297
  {
7293
7298
  "path": "tests/platform_boot.mjs",
7294
7299
  "mode": "0000644",
7295
- "sha256": "de3bbe68cb648ad5027506f7dcf848e2758e9e67ea65d8c7fb27febbab264845"
7300
+ "sha256": "3552e9e4e947c21770d734b340893db576ea42c7b5de9d2822021a03e1345810"
7296
7301
  },
7297
7302
  {
7298
7303
  "path": "tests/post_login_landing.mjs",
@@ -7579,6 +7584,11 @@
7579
7584
  "mode": "0000644",
7580
7585
  "sha256": "921c24a356d0fc1f398563bab3e8bb6f0831919a6954d86b6d74fdc60ff3b5f9"
7581
7586
  },
7587
+ {
7588
+ "path": "tests/session_attach_from_claim.mjs",
7589
+ "mode": "0000644",
7590
+ "sha256": "e76a3baf5fb2cdee08ffe8d80c55630b5ab4f708046f3c63c07c480926ccda31"
7591
+ },
7582
7592
  {
7583
7593
  "path": "tests/session_caps.mjs",
7584
7594
  "mode": "0000644",
@@ -7612,7 +7622,7 @@
7612
7622
  {
7613
7623
  "path": "tests/session_reward_on_land.mjs",
7614
7624
  "mode": "0000644",
7615
- "sha256": "62d0b356029aca4ea307957951aba7ec677598c152ce68caee5001c6c36f9323"
7625
+ "sha256": "52be7bd7afd17f7f7d1c93efb9be32902d08062cd65f35e3e3709a0e3669050f"
7616
7626
  },
7617
7627
  {
7618
7628
  "path": "tests/session_reward_reconcile.mjs",
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "rankLabels": { "xenos": "Newcomer", "thetes": "Contributor", "metic": "Trusted", "archon": "Owner" },
53
53
  "copy": {
54
- "tagline": "Building software is laying down a beat.",
54
+ "tagline": "Building Digitally, as simple as Bongos.",
55
55
  "hallName": "Builders' Hall",
56
56
  "notFound": "This page skipped a beat.",
57
57
  "authError": {
@@ -8,6 +8,6 @@
8
8
  "APP_ID": "com.cloudbongos.devbox",
9
9
  "COPYRIGHT": "© Cloud Bongos",
10
10
  "CONFIG_DIR": "cloudbongos",
11
- "TAGLINE": "Building software is laying down a beat.",
11
+ "TAGLINE": "Building Digitally, as simple as Bongos.",
12
12
  "BG_COLOR": "#f9d9a8"
13
13
  }
@@ -191,6 +191,67 @@ async function upsertSessionRecordWithReward(opts) {
191
191
  });
192
192
  }
193
193
 
194
+ // attachClaimSessionsToTaskTx — SERVER-SIDE task↔session attribution (task
195
+ // 1002964). Every payout path keys on session_records.task_ids, and until this
196
+ // existed exactly ONE thing ever populated that column: ship.js's client-side
197
+ // upload (`task_ids: [taskId]`). When that upload skipped — no
198
+ // CLAUDE_CODE_SESSION_ID, an unsafe id, or a transcript the client couldn't find
199
+ // (classifyUploadSkip, scripts/gds/ship.js) — the session was left with an EMPTY
200
+ // task_ids, which matches NOTHING in awardDeferredSessionRewardForTask,
201
+ // reconcileUnbookedSessionRewards, or the upload-side gate. The reward then
202
+ // stranded PERMANENTLY, not late: even the state-based safety net could not see
203
+ // the row. Builder IAMMASTERQUA hit exactly this — 19 sessions, 19 empty, ~$730
204
+ // of recorded session cost, zero paid, for ten weeks, with the only diagnostic
205
+ // (recordUploadSkip's trace) written to their own laptop.
206
+ //
207
+ // The server does not need the client's help here: a claim already records WHO
208
+ // shipped WHAT and WHEN. So we recover the link from durable state — the
209
+ // builder's session_records whose activity window overlaps the claim window.
210
+ //
211
+ // Design notes:
212
+ // • OVERLAP, not containment: a session that was already running when the
213
+ // claim opened (or outlived it) still did that work. started_at/ended_at
214
+ // fall back to uploaded_at when the transcript carried no span.
215
+ // • OVER-ATTACHMENT IS FINANCIALLY SAFE. The reward is idempotent per
216
+ // session_id (the watermark SELECT + uniq_credit_session_token_reward_basis),
217
+ // so linking one session to several tasks pays it ONCE, never per task. The
218
+ // bounded downside is a longer task_ids list, not double-pay.
219
+ // • Bounded by `limit` so a pathological long-running session can't fan out
220
+ // an unbounded UPDATE.
221
+ // • Does NOT touch the landing gate. It only answers "which session did this
222
+ // task come from"; whether the task LANDED is still checked by every caller
223
+ // (ADR 0120 — never pay for code that didn't land).
224
+ // Returns { attached } (rows freshly linked).
225
+ async function attachClaimSessionsToTaskTx(client, taskId, { limit = 50 } = {}) {
226
+ const id = Number(taskId);
227
+ if (!Number.isFinite(id)) return { attached: 0 };
228
+ const n = Number(limit);
229
+ const cap = Number.isFinite(n) ? Math.max(1, Math.min(500, Math.trunc(n))) : 50;
230
+ const { rows } = await client.query(
231
+ `WITH cand AS (
232
+ SELECT DISTINCT sr.id
233
+ FROM claims c
234
+ JOIN session_records sr ON sr.builder_id = c.builder_id
235
+ WHERE c.task_id = $1
236
+ AND NOT (sr.task_ids @> ARRAY[$1]::bigint[])
237
+ AND COALESCE(sr.started_at, sr.uploaded_at) <= COALESCE(c.released_at, now())
238
+ AND COALESCE(sr.ended_at, sr.uploaded_at) >= c.claimed_at
239
+ ORDER BY sr.id
240
+ LIMIT $2
241
+ )
242
+ UPDATE session_records sr
243
+ SET task_ids = (
244
+ SELECT COALESCE(array_agg(DISTINCT e ORDER BY e), '{}')
245
+ FROM unnest(sr.task_ids || ARRAY[$1]::bigint[]) AS e
246
+ )
247
+ FROM cand
248
+ WHERE cand.id = sr.id
249
+ RETURNING sr.session_id`,
250
+ [id, cap]
251
+ );
252
+ return { attached: rows.length };
253
+ }
254
+
194
255
  // awardDeferredSessionRewardForTask — the LAND-side half of ADR 0120 "pay on
195
256
  // land". When a task reaches status='shipped', routes/sessions.js's task.shipped
196
257
  // listener calls this with the shipped task id. It finds every session_record
@@ -198,16 +259,24 @@ async function upsertSessionRecordWithReward(opts) {
198
259
  // ci/async path, where the digest was uploaded (and DEFERRED) before the code
199
260
  // landed. Opens its own transaction.
200
261
  //
262
+ // It first runs attachClaimSessionsToTaskTx (task 1002964) so a session whose
263
+ // client-side upload skipped is still found: before that, an unsent task_ids
264
+ // made the reward unreachable by every path, permanently.
265
+ //
201
266
  // We do NOT pre-filter on token_reward_drachmae: awardSessionTokenReward is
202
267
  // idempotent per session_id, so a session that already collected at upload time
203
268
  // (sync mode) — or a re-fired event — is a clean no-op. The credit_log is the
204
269
  // single source of truth for "already paid"; the mirror column is only
205
- // observability, so it is never the idempotency key. Returns { sessions, awarded }
206
- // (rows scanned, rewards freshly booked).
270
+ // observability, so it is never the idempotency key.
271
+ // Returns { sessions, awarded, attached } (rows scanned, rewards freshly booked,
272
+ // links freshly recovered).
207
273
  async function awardDeferredSessionRewardForTask(taskId) {
208
274
  const id = Number(taskId);
209
- if (!Number.isFinite(id)) return { sessions: 0, awarded: 0 };
275
+ if (!Number.isFinite(id)) return { sessions: 0, awarded: 0, attached: 0 };
210
276
  return api.withTx(async (client) => {
277
+ // Recover any link the client-side upload failed to send BEFORE looking for
278
+ // payable sessions — otherwise a skipped upload is unpayable forever.
279
+ const { attached } = await attachClaimSessionsToTaskTx(client, id);
211
280
  const { rows } = await client.query(
212
281
  `SELECT session_id, builder_id, model_usage, total_tokens
213
282
  FROM session_records
@@ -224,7 +293,7 @@ async function awardDeferredSessionRewardForTask(taskId) {
224
293
  });
225
294
  if (tokenReward && tokenReward.awarded && tokenReward.drachmae > 0) awarded += 1;
226
295
  }
227
- return { sessions: rows.length, awarded };
296
+ return { sessions: rows.length, awarded, attached };
228
297
  });
229
298
  }
230
299
 
@@ -287,6 +356,64 @@ async function reconcileUnbookedSessionRewards({ limit = 200 } = {}) {
287
356
  });
288
357
  }
289
358
 
359
+ // reconcileUnattachedClaimSessions — the ATTRIBUTION half of the safety net
360
+ // (task 1002964), and the reason reconcileUnbookedSessionRewards alone was not
361
+ // enough. That sweep starts from session_records.task_ids, so a session the
362
+ // client never linked is invisible to it — the very rows most in need of rescue
363
+ // are the ones it cannot see. This sweep runs FIRST and comes at it from the
364
+ // other side: start from LANDED tasks, walk their claims, and link the claiming
365
+ // builder's overlapping sessions. Once linked, the existing sweep pays them.
366
+ //
367
+ // Ordering matters: the poller calls this, then reconcileUnbookedSessionRewards.
368
+ // Same idempotency story as attachClaimSessionsToTaskTx — a session already
369
+ // carrying the task is skipped by the NOT (task_ids @> …) guard, and the payout
370
+ // stays exactly-once per session regardless of how many tasks it links to.
371
+ // Strictly pay-on-land: only tasks at status='shipped' are considered.
372
+ //
373
+ // ⚠️ LOOKBACK WINDOW — this is a SAFETY NET, NOT A BACKFILL. The reward sweep
374
+ // runs immediately after this one, so every task linked here becomes payable on
375
+ // the same tick. Unbounded, the first run would retro-link the ENTIRE history
376
+ // (measured on cloudbongos.com 2026-08-16: 1383 tasks / 1157 sessions for one
377
+ // builder alone) and mint a mass retroactive payout nobody authorised. Deliberate
378
+ // historical back-pay is an Archon decision made with eyes open via
379
+ // scripts/gds/backfill-task-rewards.js (dry-run by default, reviewed amounts) —
380
+ // never a side effect of a poller starting up. So we only consider tasks that
381
+ // landed inside the window: recent enough that a live client-side upload skip is
382
+ // what we are recovering from. Widen it deliberately, never casually.
383
+ // Returns { scanned, attached }.
384
+ const ATTACH_LOOKBACK_DAYS = Number(process.env.SESSION_ATTACH_LOOKBACK_DAYS) || 7;
385
+
386
+ async function reconcileUnattachedClaimSessions({ limit = 200, lookbackDays } = {}) {
387
+ const n = Number(limit);
388
+ const cap = Number.isFinite(n) ? Math.max(1, Math.min(1000, Math.trunc(n))) : 200;
389
+ const d = Number(lookbackDays ?? ATTACH_LOOKBACK_DAYS);
390
+ const days = Number.isFinite(d) ? Math.max(1, Math.min(365, Math.trunc(d))) : 7;
391
+ return api.withTx(async (client) => {
392
+ // Candidate LANDED tasks whose claiming builder has at least one overlapping
393
+ // session that is not yet linked. Bounded, newest first — a backlog drains
394
+ // over successive sweeps rather than in one unbounded UPDATE.
395
+ const { rows } = await client.query(
396
+ `SELECT DISTINCT c.task_id
397
+ FROM claims c
398
+ JOIN tasks t ON t.id = c.task_id AND t.status = 'shipped'
399
+ JOIN session_records sr ON sr.builder_id = c.builder_id
400
+ WHERE NOT (sr.task_ids @> ARRAY[c.task_id]::bigint[])
401
+ AND COALESCE(t.shipped_at, t.updated_at) >= now() - ($2 || ' days')::interval
402
+ AND COALESCE(sr.started_at, sr.uploaded_at) <= COALESCE(c.released_at, now())
403
+ AND COALESCE(sr.ended_at, sr.uploaded_at) >= c.claimed_at
404
+ ORDER BY c.task_id DESC
405
+ LIMIT $1`,
406
+ [cap, String(days)]
407
+ );
408
+ let attached = 0;
409
+ for (const r of rows) {
410
+ const res = await attachClaimSessionsToTaskTx(client, r.task_id);
411
+ attached += res.attached;
412
+ }
413
+ return { scanned: rows.length, attached };
414
+ });
415
+ }
416
+
290
417
  // ---- 6D.2: session search + fetch (read side, ADR 0027) -------------------
291
418
 
292
419
  // Safe ORDER BY whitelist — the ONLY value interpolated into the search SQL, so
@@ -459,6 +586,8 @@ module.exports = {
459
586
  upsertSessionRecordWithReward, // route entry point — upsert + token reward (task 1005)
460
587
  awardDeferredSessionRewardForTask, // ADR 0120 land-side trigger — task.shipped listener calls it
461
588
  reconcileUnbookedSessionRewards, // ADR 0120 state-based safety net — books rewards for out-of-band ships
589
+ attachClaimSessionsToTaskTx, // task 1002964 — server-side task↔session link, recovered from the claim
590
+ reconcileUnattachedClaimSessions, // task 1002964 — attribution sweep; must run BEFORE the reward sweep
462
591
  // search + fetch (6D.2)
463
592
  searchSessions,
464
593
  summarizeSessions,
@@ -29,13 +29,25 @@ let intervalTimer = null;
29
29
  let firstTimer = null;
30
30
 
31
31
  async function runSweep() {
32
+ // ATTRIBUTION FIRST (task 1002964). reconcileUnbookedSessionRewards starts from
33
+ // session_records.task_ids, so a session whose client-side upload skipped — and
34
+ // which therefore carries an EMPTY task_ids — is invisible to it. Those are
35
+ // precisely the stranded rows worth rescuing, so recover the task↔session link
36
+ // from the claim before looking for unbooked rewards. Both halves are
37
+ // idempotent, so a re-sweep neither re-links nor double-pays.
38
+ const attach = await db.reconcileUnattachedClaimSessions({ limit: 200 });
39
+ if (attach && attach.attached > 0) {
40
+ console.log(
41
+ `[session-reward-reconcile] linked ${attach.attached} session(s) to landed tasks from claim state (scanned ${attach.scanned})`
42
+ );
43
+ }
32
44
  const res = await db.reconcileUnbookedSessionRewards({ limit: 200 });
33
45
  if (res && res.awarded > 0) {
34
46
  console.log(
35
47
  `[session-reward-reconcile] booked ${res.awarded} stranded session reward(s) (scanned ${res.scanned})`
36
48
  );
37
49
  }
38
- return res;
50
+ return { ...res, attached: attach ? attach.attached : 0 };
39
51
  }
40
52
 
41
53
  // Start the periodic sweep. Idempotent (a second call is a no-op). Kill-switched by
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.154",
3
+ "version": "1.19.156",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.154",
9
+ "version": "1.19.156",
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.154",
3
+ "version": "1.19.156",
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",
@@ -144,11 +144,62 @@ h1.big{font-family:var(--font-display);font-weight:700;font-size:clamp(33px,5.4v
144
144
  at rest and would otherwise sit visible at full stroke. */
145
145
  @media (prefers-reduced-motion:reduce){.bongos .ring{display:none;}}
146
146
 
147
- /* ── S3 · the mechanism ── */
148
- .beats{--col:238px;}
149
- .beat{background:var(--card);border-radius:22px;box-shadow:var(--shadow-card);padding:clamp(19px,2.5vw,26px);}
150
- .beatNum{font-family:var(--font-mono);font-size:12px;font-weight:500;color:var(--accent);letter-spacing:1.3px;text-transform:uppercase;margin-bottom:11px;}
151
- .beat p{font-size:var(--fs-body);font-weight:500;color:var(--ink-soft);line-height:1.58;}
147
+ /* ── S3 · the builder-credit strip (apex-only content; classes .verro*) ──
148
+ Deliberately a different house from the rest of the page. Its palette is a
149
+ LOCAL token set, not the Bongos one: cool greys that hold in both schemes,
150
+ so the block reads as an inset plaque rather than another warm card. Only
151
+ these five tokens are local — everything else still comes from cosmos.css. */
152
+ .verro{
153
+ --v-ground:#e9ecec; --v-surface:#f3f5f5; --v-ink:#4e5457;
154
+ --v-ink-strong:#33383b; --v-hair:#aeb6b8;
155
+ background:var(--v-ground);border-radius:20px;
156
+ padding:clamp(18px,2.4vw,26px) clamp(20px,2.8vw,30px);
157
+ box-shadow:var(--shadow-card);overflow:hidden;
158
+ }
159
+ @media (prefers-color-scheme:dark){
160
+ .verro{--v-ground:#15181a;--v-surface:#1b1f21;--v-ink:#aeb6b9;--v-ink-strong:#d6dcde;--v-hair:#3d4548;}
161
+ }
162
+ :root[data-theme="dark"] .verro{--v-ground:#15181a;--v-surface:#1b1f21;--v-ink:#aeb6b9;--v-ink-strong:#d6dcde;--v-hair:#3d4548;}
163
+ :root[data-theme="light"] .verro{--v-ground:#e9ecec;--v-surface:#f3f5f5;--v-ink:#4e5457;--v-ink-strong:#33383b;--v-hair:#aeb6b8;}
164
+ /* mark LEFT, text RIGHT — a fixed logo column, the copy takes the rest */
165
+ .verroInner{position:relative;display:grid;grid-template-columns:124px 1fr;gap:clamp(18px,2.6vw,30px);align-items:center;}
166
+ .verroMark{
167
+ font-family:system-ui,-apple-system,"Segoe UI",Arial,sans-serif;font-weight:400;
168
+ font-size:clamp(19px,2.6vw,26px);letter-spacing:.075em;line-height:1;
169
+ color:var(--v-ink-strong);margin-bottom:11px;
170
+ text-shadow:-1px 0 rgba(214,120,40,.26),1px 0 rgba(60,120,190,.26);
171
+ }
172
+ .verroRule{width:40px;height:1px;background:var(--v-hair);margin-bottom:12px;}
173
+ .verro p{
174
+ position:relative;font-family:system-ui,-apple-system,"Segoe UI",Arial,sans-serif;
175
+ font-size:clamp(14px,1.5vw,15.5px);font-weight:400;line-height:1.58;
176
+ color:var(--v-ink);max-width:62ch;
177
+ }
178
+ .verroMedal{justify-self:center;}
179
+ /* No border-radius needed: the PNG is already masked to a transparent-cornered
180
+ disc, so it sits on the grey block with nothing to clip. Width/height are
181
+ also set as attributes on the element so the box is reserved before the
182
+ image loads and the block does not reflow. */
183
+ .verroMedal .verroDisc{width:124px;height:124px;display:block;}
184
+ @media (max-width:760px){
185
+ .verroInner{grid-template-columns:1fr;justify-items:center;text-align:center;}
186
+ .verroRule{margin-left:auto;margin-right:auto;}
187
+ }
188
+
189
+
190
+ /* ── S6b · the basics ── */
191
+ .basics{--col:300px;align-items:stretch;}
192
+ .basic{background:var(--card);border-radius:22px;box-shadow:var(--shadow-card);padding:clamp(20px,2.8vw,28px);}
193
+ .basicWho{font-family:var(--font-mono);font-size:12px;font-weight:500;color:var(--accent);letter-spacing:1.3px;text-transform:uppercase;margin-bottom:11px;}
194
+ .basic ul{list-style:none;margin:14px 0 0;padding:0;display:flex;flex-direction:column;gap:13px;}
195
+ .basic li{position:relative;padding-left:19px;font-size:var(--fs-body);font-weight:500;color:var(--ink-soft);line-height:1.55;}
196
+ .basic li::before{content:"";position:absolute;left:0;top:.62em;width:7px;height:7px;border-radius:50%;background:var(--accent);}
197
+ .basic li b{font-weight:700;color:var(--ink);}
198
+ /* second level: the points that fold up under a tier. Hollow marker so the
199
+ nesting reads as subordinate rather than as another top-level choice. */
200
+ .basic li ul{margin:9px 0 0;gap:7px;}
201
+ .basic li li{padding-left:16px;font-size:calc(var(--fs-body) - .5px);}
202
+ .basic li li::before{top:.66em;width:5px;height:5px;background:transparent;border:1.5px solid var(--accent);}
152
203
 
153
204
  /* ── S4 · the lifecycle: cosmos.css's .strip, unmodified, doing marketing work.
154
205
  The marketing page and the working page share the component rather than
@@ -179,10 +230,10 @@ h1.big{font-family:var(--font-display);font-weight:700;font-size:clamp(33px,5.4v
179
230
  .conTag{font-size:12.5px;font-weight:600;color:rgba(255,246,230,.58);margin-top:3px;line-height:1.35;display:block;}
180
231
  .conItem .chip{margin-top:8px;white-space:nowrap;}
181
232
 
182
- /* ── S6/S7 ── */
183
- .own{background:var(--card);border-radius:24px;box-shadow:var(--shadow-card);padding:clamp(22px,3.2vw,34px);}
184
- .door{background:var(--card);border-radius:22px;box-shadow:var(--shadow-card);padding:clamp(20px,2.8vw,28px);display:flex;flex-direction:column;gap:10px;align-items:flex-start;}
185
- .door p{font-size:var(--fs-body);font-weight:500;color:var(--ink-soft);line-height:1.55;flex:1;}
233
+ /* ── S7 ── */
234
+ /* S7 closes on two bare buttons — the door cards are gone (task 1002934), and
235
+ .door/.door p went with them rather than lingering as dead rules. */
236
+ .closing .cta-row{justify-content:center;}
186
237
 
187
238
  @media (max-width:900px){
188
239
  .lead{grid-template-columns:1fr;text-align:center;}
@@ -238,11 +289,16 @@ h1.big{font-family:var(--font-display);font-weight:700;font-size:clamp(33px,5.4v
238
289
  <section class="lead">
239
290
  <div>
240
291
  <p class="eyebrow">type <b>bongos</b> in <span class="typed" id="typed">any AI</span><span class="caret" aria-hidden="true">▌</span></p>
241
- <h1 class="big" data-brand-tagline>Building software is laying&nbsp;down a beat.</h1>
242
- <p class="lede">AI agents claim tasks and ship the code; you set the direction and the gates.</p>
292
+ <h1 class="big" data-brand-tagline>Building Digitally, as simple as&nbsp;Bongos.</h1>
293
+ <p class="lede">AI agents claim tasks and ship code. You direct them.</p>
294
+ <!-- ONE button by owner direction (task 1002934). The former ghost CTA
295
+ ("See what's building") is gone; the survivor keeps .btn.lg exactly as
296
+ it was — a deletion, not a restyle. It now points at /projects rather
297
+ than /projects?view=new because the label is an invitation to browse,
298
+ not to create. NOTE for a child instance: /projects there means that
299
+ instance's own provisioning rows, not the hub catalog. -->
243
300
  <div class="cta-row">
244
- <a class="btn lg" href="/projects?view=new">Start a project</a>
245
- <!--apex-only:start--><a class="btn ghost lg" href="/projects">See what's building</a><!--apex-only:end-->
301
+ <a class="btn lg" href="/projects">Explore Bongo Projects</a>
246
302
  </div>
247
303
  </div>
248
304
  <div class="mascot">
@@ -329,41 +385,64 @@ h1.big{font-family:var(--font-display);font-weight:700;font-size:clamp(33px,5.4v
329
385
  </section>
330
386
  </div>
331
387
 
332
- <!-- ══ S3 · THE MECHANISM ══════════════════════════════════════════════════
333
- "AI writes code" is a crowded claim and not the differentiator. What is
334
- actually enforced — a complete ledger, a server-checked ladder, an
335
- adversarial grade — is. All three are portable: a child instance inherits
336
- them, so none of this section is apex-only.
388
+ <!--apex-only:start-->
389
+ <!-- ══ S3 · VERRO LABS ═════════════════════════════════════════════════════
390
+ Replaces the three mechanism cards (the ledger / the gates / the grade) by
391
+ owner direction, task 1002934.
392
+
393
+ APEX-ONLY, and it must stay that way: a child instance is NOT built by
394
+ Verro Labs and must never say so. The block the beats used to occupy was
395
+ deliberately portable; this one cannot be. Consequence a child instance
396
+ now has nothing between the hero and the lifecycle — flagged to the owner
397
+ as a follow-up decision, not silently absorbed.
398
+
399
+ The design is drawn FROM the Verro Labs logo so it reads as a different
400
+ house inside the warm Bongos page: cool grey monochrome, a wide-tracked
401
+ uppercase wordmark, and a dialled-down chromatic fringe.
402
+
403
+ NO HAIRLINE FRAME. An earlier pass added one, reading the thin rectangle
404
+ in the logo lockup as part of the mark; the owner confirmed it was not
405
+ meant to be there. Do not reintroduce it.
337
406
  ═══════════════════════════════════════════════════════════════════════ -->
338
407
  <div class="wrap">
339
- <section class="sec grid beats">
340
- <div class="beat">
341
- <div class="beatNum">the ledger</div>
342
- <h3 class="sec-h3">Every change is a claimed task</h3>
343
- <p>No admin loophole, no tiny-fix loophole. If work happened, there is a row that says who did
344
- it, what it cost, and why. The ledger is complete or it lies.</p>
345
- </div>
346
- <div class="beat">
347
- <div class="beatNum">the gates</div>
348
- <h3 class="sec-h3">A rank ladder the server enforces</h3>
349
- <p>Authority lives in the database, checked per request, never cached. An agent editing its own
350
- instructions grants itself nothing.</p>
351
- </div>
352
- <div class="beat">
353
- <div class="beatNum">the grade</div>
354
- <h3 class="sec-h3">Nothing ships ungraded</h3>
355
- <p>An adversarial panel reads the real committed diff before work lands — not the summary the
356
- agent wrote about itself.</p>
408
+ <section class="sec verro">
409
+ <div class="verroInner">
410
+ <!-- mark first in source order as well as visually, so it reads the same
411
+ way to a screen reader and stacks logo-above-text on a narrow screen
412
+ without needing an order override -->
413
+ <div class="verroMedal">
414
+ <!-- The Verro Labs medallion. Cropped from the supplied artwork to the
415
+ circle only and masked to a transparent-cornered disc, so there is
416
+ no white plate and no square frame to hide with CSS. The file is
417
+ 384px against a 124px box — comfortably past 2x, so it stays sharp
418
+ if the block is ever scaled back up.
419
+
420
+ NOTE ON SHARPNESS: the source was a 217x210 screenshot and the disc
421
+ within it only 193x197, so this is upscaled and will look soft on a
422
+ high-DPI screen. If a higher-resolution original (or a vector)
423
+ exists, re-crop from that — the recipe is in the task notes. -->
424
+ <img class="verroDisc" src="assets/verro-medallion.png"
425
+ width="124" height="124" loading="lazy" decoding="async"
426
+ alt="The Verro Labs mark: a galaxy and constellation above a river valley threaded with circuitry">
427
+ </div>
428
+ <div>
429
+ <div class="verroMark">VERRO LABS</div>
430
+ <div class="verroRule"></div>
431
+ <p>Built by Verro Labs, Cloud Bongos is our way of making AI co-building usable by entire
432
+ project groups — whether that's a 5,000-person company, a 3,000-person department inside
433
+ one, a college research project, or a solo dev.</p>
434
+ </div>
357
435
  </div>
358
436
  </section>
359
437
  </div>
360
438
 
439
+ <!--apex-only:end-->
440
+
361
441
  <!-- ══ S4 · THE LIFECYCLE ══════════════════════════════════════════════════ -->
362
442
  <div class="wrap">
363
443
  <section class="sec railWrap">
364
- <h2 class="sec-h">How a change reaches your project</h2>
365
- <p class="sec-p">The same five beats every task moves through — the ones you'll watch on your own
366
- projects page.</p>
444
+ <h2 class="sec-h">A project as a codebase, regardless of function.</h2>
445
+ <p class="sec-p">A laddered beat progresses all tasks.</p>
367
446
  <div class="strip isLive" role="img" aria-label="claim, build, grade, ship, credit">
368
447
  <ul class="stages">
369
448
  <li class="done"><span class="sdot"></span><span class="slbl">claim</span></li>
@@ -391,7 +470,8 @@ h1.big{font-family:var(--font-display);font-weight:700;font-size:clamp(33px,5.4v
391
470
  <div class="bandHead">
392
471
  <div>
393
472
  <h2 class="sec-h">Built on Cloud Bongos</h2>
394
- <p class="bandSub">Real projects, standing up right now.</p>
473
+ <p class="bandSub">At present, Bongos projects are all held by Verro Labs or its pilot
474
+ clients.</p>
395
475
  </div>
396
476
  <a class="linklike" href="/projects">Open the map →</a>
397
477
  </div>
@@ -401,43 +481,61 @@ h1.big{font-family:var(--font-display);font-weight:700;font-size:clamp(33px,5.4v
401
481
  <!--apex-only:end-->
402
482
 
403
483
  <!--apex-only:start-->
404
- <!-- ══ S6 · WHAT YOU OWN ═══════════════════════════════════════════════════
405
- Copy verbatim from create.html — already owner-approved and already
406
- public-facing. It is a claim about Cloud Bongos the platform, so it does
407
- not travel to a child instance.
484
+ <!-- ══ S6b · THE BASICS ═════════════════════════════════════════════════════
485
+ New dual block, owner direction (task 1002934). Inside the existing
486
+ apex-only run: it describes Verro Labs' commercial offering, which is not
487
+ true of a child instance.
488
+
489
+ Placement is a planner's call — the last block before the closing
490
+ doors, so the practical explainer sits just ahead of the final ask.
408
491
  ═══════════════════════════════════════════════════════════════════════ -->
409
492
  <div class="wrap">
410
- <section class="sec own">
411
- <h2 class="sec-h">What you own</h2>
412
- <p class="sec-p" style="margin-bottom:18px;">Free while we find our feet. Your project's repo and
413
- server are yours — Cloud Bongos is
414
- <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">open source
415
- (AGPL-3.0)</a> and capture-resistant by design. Run the whole thing on your own box and owe us
416
- nothing; the hosted version is a convenience, not a lock.</p>
417
- <!-- server-stamped from the pack's repo binding, never baked: a hardcoded
418
- owner/name would point every instance at whoever authored this file, and
419
- the authorship audit fails it. Dropped below if the pack names no repo. -->
420
- <a class="btn ghost" href="{{sourceRepoUrl}}" target="_blank" rel="noopener">Read the source →</a>
493
+ <section class="sec">
494
+ <div class="grid pair basics">
495
+ <div class="basic">
496
+ <div class="basicWho">For founders</div>
497
+ <h3 class="sec-h3">Types of projects</h3>
498
+ <ul>
499
+ <li><b>Open source.</b> Download our core code and protocols, and build instantly with
500
+ your team.</li>
501
+ <!-- "Bongos Managed" and "Cloud Bongos Managed" were two separate tiers with
502
+ near-identical names, which read as the same thing said twice. Consolidated
503
+ into one tier by owner direction; every point from both now sits under it. -->
504
+ <li><b>Bongos Managed.</b>
505
+ <ul>
506
+ <li>Buy microtransaction packages of code modules on our marketplace.</li>
507
+ <li>Have us handle your hosting.</li>
508
+ <li>Get access to our advanced project presets and module packages.</li>
509
+ <li>Equity issuance, business filing, and builder compensation.</li>
510
+ </ul>
511
+ </li>
512
+ </ul>
513
+ </div>
514
+ <div class="basic">
515
+ <div class="basicWho">For builders</div>
516
+ <h3 class="sec-h3">Ways to get paid</h3>
517
+ <ul>
518
+ <li><b>Build equity</b> in multiple projects.</li>
519
+ <li><b>Work as a contractor</b> for companies big, medium and small — and get paid
520
+ instantly.</li>
521
+ <li><b>Build as a game</b> or a learning experience.</li>
522
+ </ul>
523
+ </div>
524
+ </div>
421
525
  </section>
422
526
  </div>
423
527
  <!--apex-only:end-->
424
528
 
425
- <!-- ══ S7 · TWO DOORS ══════════════════════════════════════════════════════
426
- The hero can only lead one of the two arrivals. This is the second chance
427
- for whichever one it didn't serve.
529
+ <!-- ══ S7 · THE CLOSING PAIR ═══════════════════════════════════════════════
530
+ Was two "door" cards (Build a project / Join as a builder). Reduced to two
531
+ bare buttons by owner direction, task 1002934 — the hero already made the
532
+ argument, so the page closes on the choice rather than re-pitching it.
428
533
  ═══════════════════════════════════════════════════════════════════════ -->
429
534
  <div class="wrap">
430
- <section class="sec grid pair">
431
- <div class="door">
432
- <h3 class="sec-h3">Build a project</h3>
433
- <p>Say what you want to exist. Agents take it from there, and you hold every gate that matters.</p>
434
- <a class="btn" href="/projects?view=new">Start a project</a>
435
- </div>
436
- <div class="door">
437
- <h3 class="sec-h3">Join as a builder</h3>
438
- <p>Claim tasks, ship code, earn credits. The hall is where the work is, and newcomers start
439
- with a real task on day one.</p>
440
- <a class="btn ghost" data-link-hall href="{{buildersOrigin}}">Enter the Hall →</a>
535
+ <section class="sec closing">
536
+ <div class="cta-row">
537
+ <a class="btn lg" href="/projects?view=new">Start a Project</a>
538
+ <a class="btn ghost lg" href="/projects">Find a Project</a>
441
539
  </div>
442
540
  </section>
443
541
  </div>
package/src/module-api.js CHANGED
@@ -735,8 +735,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
735
735
  // landed since 1.19.152 with no explicit bump. run 31956560292. (task 1002620)
736
736
  // 1.19.154 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
737
737
  // landed since 1.19.153 with no explicit bump. run 31957052737. (task 1002620)
738
+ // 1.19.155 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
739
+ // landed since 1.19.154 with no explicit bump. run 31957556550. (task 1002620)
740
+ // 1.19.156 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
741
+ // landed since 1.19.155 with no explicit bump. run 31957976979. (task 1002620)
738
742
  // ---------------------------------------------------------------------------
739
- const CORE_VERSION = '1.19.154'; // CI auto-patch carrier (ADR 0161); see changelog above
743
+ const CORE_VERSION = '1.19.156'; // CI auto-patch carrier (ADR 0161); see changelog above
740
744
 
741
745
  // A namespaced logger so a module's log lines are attributable + consistent.
742
746
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -44,7 +44,8 @@ test('R74: the vanilla pack carries the Cloud Bongos LOOK (sunrise palette, Balo
44
44
  assert.match(c.theme.favicon, /circle/, 'bongo-pair favicon (circles), not the Greek Α');
45
45
  assert.doesNotMatch(c.theme.favicon, /font-family='serif'>Α/);
46
46
  assert.equal(c.theme.backdrop, '#f9d9a8', 'the marigold ground holds all the way down');
47
- assert.equal(c.copy.tagline, 'Building software is laying down a beat.');
47
+ // task 1002934: the owner's homepage rewrite retitled the platform.
48
+ assert.equal(c.copy.tagline, 'Building Digitally, as simple as Bongos.');
48
49
  assert.equal(c.rankLabels.archon, 'Owner', 'plain rank labels, not "Archon"');
49
50
  });
50
51
 
@@ -55,20 +55,22 @@ test('the hub keeps every apex-only section', () => {
55
55
  test('a child instance is served none of the platform-only claims', () => {
56
56
  const child = stripApexOnly(HTML, false);
57
57
  assert.doesNotMatch(child, /<!--apex-only:(start|end)-->/, 'no sentinel survives the strip');
58
- // the four regions, by their load-bearing content
58
+ // the apex-only regions, by their load-bearing content (task 1002934: the
59
+ // Verro Labs strip + The Basics replaced the beats / What-you-own era)
59
60
  assert.doesNotMatch(child, /capture-resistant commons/, 'the commons claim is the platform\'s, not a child\'s');
60
61
  assert.doesNotMatch(child, /Built on Cloud Bongos/, 'a child has no catalog of other instances');
61
- assert.doesNotMatch(child, /What you own/, 'the AGPL/ownership pitch is the platform\'s');
62
- assert.doesNotMatch(child, /See what's building/, 'the catalog CTA has nothing to point at');
62
+ assert.doesNotMatch(child, /Verro Labs/, 'a child instance is not built by Verro Labs');
63
+ assert.doesNotMatch(child, /Bongos Managed/, 'the commercial offer is the platform\'s, not a child\'s');
64
+ assert.doesNotMatch(child, /thousands of builders/, 'a child has no hub builder pool to promise');
63
65
  });
64
66
 
65
67
  test('a child instance keeps the whole portable spine', () => {
66
68
  const child = stripApexOnly(HTML, false);
67
69
  for (const portable of [
68
- 'Building software is laying', // the hero tagline
69
- 'the ledger', 'the gates', 'the grade', // S3, the differentiator
70
- 'How a change reaches your project', // S4
71
- 'Build a project', 'Join as a builder', // S7, both doors
70
+ 'Building Digitally, as simple as', // the hero tagline (task 1002934)
71
+ 'A project as a codebase', // S4 heading (task 1002934 rewording)
72
+ 'A laddered beat progresses all tasks', // S4 subtitle
73
+ 'Start a Project', 'Find a Project', // S7, the closing pair (buttons since 1002934)
72
74
  'Built by agents, on Cloud', // the portable half of the footer
73
75
  'id="who"', // the identity chip
74
76
  ]) {
@@ -109,7 +111,9 @@ test('every head tag a crawler reads is stamped, and no token survives', () => {
109
111
  test('every cross-host link is stamped to THIS instance, never the platform', () => {
110
112
  const out = applyBrandTokens(HTML, BRAND, 'https://spike-harbor.example', 'https://builders.spike-harbor.example');
111
113
  const halls = [...out.matchAll(/data-link-hall href="([^"]*)"/g)].map((m) => m[1]);
112
- assert.ok(halls.length >= 2, 'the header door and the builders door both point at the hall');
114
+ // task 1002934: the S7 door cards (which carried the second hall link) became
115
+ // two bare project buttons — the header Hall entry is the one that remains.
116
+ assert.ok(halls.length >= 1, 'the header Hall entry points at the hall');
113
117
  for (const href of halls) assert.equal(href, 'https://builders.spike-harbor.example');
114
118
  // C6: this is the bug the stamping fixes — clientBranding() omits domains, so a
115
119
  // client fill can never re-point these and every child sent visitors to the platform.
@@ -121,19 +125,21 @@ test('a pack with no hall of its own falls back to its own apex, not the platfor
121
125
  assert.match(out, /data-link-hall href="https:\/\/spike-harbor\.example"/);
122
126
  });
123
127
 
124
- test('the source link is stamped from the pack repo, never a baked owner/name', () => {
125
- // the authorship audit fails a hardcoded owner/name, and a baked one would
126
- // point every instance at whoever authored this file
128
+ test('no repo path is baked into the page', () => {
129
+ // task 1002934 removed the "What you own" section and with it the page's only
130
+ // {{sourceRepoUrl}} link (owner-accepted). What must still hold: no HARDCODED
131
+ // owner/name — a baked one would point every instance at whoever authored
132
+ // this file, and the authorship audit fails it.
127
133
  assert.doesNotMatch(HTML, /github\.com\/[a-z0-9-]+\/[a-z0-9-]+/i, 'no baked repo path');
128
- const out = applyBrandTokens(HTML, BRAND, 'https://o.example', '', 'https://github.com/acme/harbor');
129
- assert.match(out, /href="https:\/\/github\.com\/acme\/harbor"/);
130
134
  });
131
135
 
132
136
  test('an unstamped link is removed, not left dangling', () => {
133
- const out = applyBrandTokens(HTML, BRAND, 'https://o.example', '', '');
134
- assert.match(out, /href=""/, 'the token resolves empty when the pack names no repo');
135
- assert.match(CODE, /a\[href=""\]/, 'and the page drops those links');
137
+ // the page keeps its empty-href scrubber so ANY future unstamped token link
138
+ // (the header hall entry today) is dropped rather than rendered dead.
139
+ const out = applyBrandTokens(HTML, BRAND, 'https://o.example', '');
140
+ assert.match(CODE, /a\[href=""\]/, 'the page drops empty-href links');
136
141
  assert.match(CODE, /\.remove\(\)/);
142
+ assert.ok(out.length > 0);
137
143
  });
138
144
 
139
145
  // ── the branding cascade + portability ─────────────────────────────────────
@@ -238,13 +244,12 @@ test('nothing about the platform is hardcoded — the file is portable (C5)', ()
238
244
  test('the page reads without JavaScript: the spine is markup, not script-built', () => {
239
245
  const body = HTML.split('<body>')[1].split('<script>')[0];
240
246
  for (const copy of [
241
- 'Building software is laying',
242
- 'Every change is a claimed task',
243
- 'A rank ladder the server enforces',
244
- 'Nothing ships ungraded',
245
- 'How a change reaches your project',
246
- 'Start a project',
247
- 'Enter the Hall',
247
+ 'Building Digitally, as simple as', // hero (task 1002934)
248
+ 'Built by Verro Labs', // the Verro strip
249
+ 'A project as a codebase', // S4 heading
250
+ 'Explore Bongo Projects', // the hero CTA
251
+ 'Start a Project', // the closing pair
252
+ 'Find a Project',
248
253
  ]) {
249
254
  assert.ok(body.includes(copy), `"${copy}" is only reachable with scripting on`);
250
255
  }
@@ -297,7 +302,7 @@ test('the five primitives graduated into cosmos.css, not the page (§6)', () =>
297
302
 
298
303
  test('the page satisfies the parked platform_boot assertions', () => {
299
304
  assert.match(HTML, /id="who"/);
300
- assert.match(HTML, /Enter the Hall/);
305
+ assert.match(HTML, /Find a Project/); // the landing marker since task 1002934
301
306
  // an ORG-level action that lives only on the gate — asserted absent there
302
307
  assert.doesNotMatch(HTML, /Create a project/);
303
308
  });
@@ -113,7 +113,9 @@ test('the apex host serves the landing page — not the game — on a game-less
113
113
  assert.doesNotMatch(res.body, /404 — not found/); // not the bare apex tail
114
114
  // it IS the landing, with the branding pack injected (look + identity from config)
115
115
  assert.match(res.body, /window\.__BRANDING__=/);
116
- assert.match(res.body, /Enter the Hall/);
116
+ // task 1002934: the doors became two bare buttons; "Find a Project" is the
117
+ // portable landing marker now ("Enter the Hall" left with the door cards).
118
+ assert.match(res.body, /Find a Project/);
117
119
  // the per-project landing carries the top-right identity chip + hall entry
118
120
  // (goal 35, task 2094). "Create a project" is an ORG-level action that lives
119
121
  // only on the gate now — it must NOT appear on a project's own landing.
@@ -0,0 +1,188 @@
1
+ // tests/session_attach_from_claim.mjs
2
+ //
3
+ // Regression guard for task 1002964 — SERVER-SIDE task↔session attribution.
4
+ //
5
+ // THE BUG. Every payout path keys on session_records.task_ids, and until this
6
+ // fix exactly ONE thing populated that column: ship.js's client-side upload.
7
+ // When that upload skipped (classifyUploadSkip: no_session_id /
8
+ // unsafe_session_id / transcript_not_found) the session was left with an EMPTY
9
+ // task_ids — which matches NOTHING in the upload-side gate,
10
+ // awardDeferredSessionRewardForTask, or reconcileUnbookedSessionRewards. The
11
+ // reward stranded PERMANENTLY, invisible even to the state-based safety net.
12
+ // Builder IAMMASTERQUA hit exactly this: 19 sessions, 19 empty, ~$730 of
13
+ // recorded session cost, zero paid, for ten weeks.
14
+ //
15
+ // THE FIX. The server recovers the link from durable claim state — the claiming
16
+ // builder's session_records whose activity window overlaps the claim window —
17
+ // so a client-side skip can no longer zero a builder's pay. What this must NOT
18
+ // do is loosen the landing gate: ADR 0120 still pays only for code that landed.
19
+ //
20
+ // Run: node tests/session_attach_from_claim.mjs
21
+
22
+ import { strict as assert } from 'node:assert';
23
+ import { createRequire } from 'node:module';
24
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
25
+
26
+ process.env.NODE_ENV = 'test';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const seams = require('../src/module-seams.js');
30
+ if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
31
+ const api = require('../src/module-api.js');
32
+ const db = require('../modules/sessions/db.js');
33
+
34
+ const { test, summary } = makeRunner();
35
+
36
+ // Same priceable fixture the sibling reward tests use: Opus 110k input →
37
+ // $0.55 true cost → ×1.20 → 1 drachma. A non-zero payout is what lets the
38
+ // idempotency watermark SELECT run at all.
39
+ const MODEL_USAGE = { 'claude-opus-4-8': { input: 110_000 } };
40
+ const WATERMARK_RE = /COALESCE\(SUM\(delta\)/;
41
+
42
+ const norm = (q) => String(q.sql).replace(/\s+/g, ' ').trim();
43
+ const find = (queries, re) => queries.find((q) => re.test(norm(q)));
44
+ const all = (queries, re) => queries.filter((q) => re.test(norm(q)));
45
+
46
+ const ATTACH_RE = /WITH cand AS .* UPDATE session_records/;
47
+ const PAYABLE_RE = /SELECT session_id, builder_id, model_usage, total_tokens FROM session_records WHERE task_ids @>/;
48
+
49
+ async function withFakeTx(handler, run) {
50
+ const fake = makeSqlAwareClient(handler);
51
+ const prev = api.withTx;
52
+ api.withTx = (fn) => fn(fake._client);
53
+ try {
54
+ const result = await run();
55
+ return { result, queries: fake.queries };
56
+ } finally {
57
+ api.withTx = prev;
58
+ }
59
+ }
60
+
61
+ // =========================================================================
62
+ // (1) The attach runs, and runs BEFORE the payable-session lookup
63
+ // =========================================================================
64
+
65
+ await test('awardDeferredSessionRewardForTask: attaches from the claim BEFORE selecting payable sessions', async () => {
66
+ const seen = [];
67
+ const { result, queries } = await withFakeTx(
68
+ (s) => {
69
+ seen.push(s);
70
+ if (ATTACH_RE.test(s)) return { rows: [{ session_id: 'sess-hook-only' }] };
71
+ if (PAYABLE_RE.test(s)) {
72
+ return { rows: [{ session_id: 'sess-hook-only', builder_id: 7, model_usage: MODEL_USAGE, total_tokens: 5000 }] };
73
+ }
74
+ if (WATERMARK_RE.test(s)) return { rows: [{ paid: 0, legacy: 0 }] };
75
+ if (/^INSERT INTO credit_log/.test(s)) return { rows: [{ id: 1 }] };
76
+ return null;
77
+ },
78
+ () => db.awardDeferredSessionRewardForTask(660)
79
+ );
80
+
81
+ const attachIdx = seen.findIndex((s) => ATTACH_RE.test(s));
82
+ const payableIdx = seen.findIndex((s) => PAYABLE_RE.test(s));
83
+ assert.ok(attachIdx >= 0, 'the claim-based attach UPDATE must run');
84
+ assert.ok(payableIdx >= 0, 'the payable-session SELECT must run');
85
+ assert.ok(attachIdx < payableIdx,
86
+ 'the attach MUST precede the payable lookup — otherwise a skipped upload is unpayable forever');
87
+ assert.equal(result.attached, 1, 'the freshly-linked session is reported');
88
+ assert.equal(result.awarded, 1, 'the hook-only session is paid once linked');
89
+ });
90
+
91
+ // =========================================================================
92
+ // (2) The end-to-end shape the bug produced: hook-only record gets paid
93
+ // =========================================================================
94
+
95
+ await test('a hook-only record (empty task_ids, empty model_usage) is attributed and paid from total_tokens', async () => {
96
+ // The exact Masterqua row shape: no task, no per-model usage — only a token
97
+ // total. Pricing must fall back to totalTokens (task 1676) or this still pays 0.
98
+ const { result, queries } = await withFakeTx(
99
+ (s) => {
100
+ if (ATTACH_RE.test(s)) return { rows: [{ session_id: 'sess-masterqua' }] };
101
+ if (PAYABLE_RE.test(s)) {
102
+ return { rows: [{ session_id: 'sess-masterqua', builder_id: 1000025, model_usage: {}, total_tokens: 77_403_545 }] };
103
+ }
104
+ if (WATERMARK_RE.test(s)) return { rows: [{ paid: 0, legacy: 0 }] };
105
+ if (/^INSERT INTO credit_log/.test(s)) return { rows: [{ id: 1 }] };
106
+ return null;
107
+ },
108
+ () => db.awardDeferredSessionRewardForTask(660)
109
+ );
110
+
111
+ const insert = find(queries, /^INSERT INTO credit_log/);
112
+ assert.ok(insert, 'an empty model_usage must still book a reward via the totalTokens fallback');
113
+ assert.equal(result.awarded, 1, 'the session that was stranded for ten weeks now pays');
114
+ });
115
+
116
+ // =========================================================================
117
+ // (3) Idempotency + the landing gate (the two things this must not break)
118
+ // =========================================================================
119
+
120
+ await test('attach SQL skips sessions already carrying the task (no re-link, no double-pay)', async () => {
121
+ const { queries } = await withFakeTx(
122
+ (s) => {
123
+ if (ATTACH_RE.test(s)) return { rows: [] }; // nothing new to link
124
+ if (PAYABLE_RE.test(s)) return { rows: [] };
125
+ return null;
126
+ },
127
+ () => db.awardDeferredSessionRewardForTask(660)
128
+ );
129
+ const attach = find(queries, ATTACH_RE);
130
+ assert.ok(/NOT \(sr\.task_ids @> ARRAY/.test(norm(attach)),
131
+ 'the attach must exclude sessions already linked to this task');
132
+ assert.equal(all(queries, /^INSERT INTO credit_log/).length, 0, 'nothing linked → nothing paid');
133
+ });
134
+
135
+ await test('attach is bounded by a LIMIT so one long session cannot fan out an unbounded UPDATE', async () => {
136
+ const { queries } = await withFakeTx(
137
+ (s) => (ATTACH_RE.test(s) || PAYABLE_RE.test(s) ? { rows: [] } : null),
138
+ () => db.awardDeferredSessionRewardForTask(660)
139
+ );
140
+ const attach = find(queries, ATTACH_RE);
141
+ assert.ok(/LIMIT \$2/.test(norm(attach)), 'the candidate set must be bounded');
142
+ assert.equal(attach.params[1], 50, 'default cap');
143
+ });
144
+
145
+ await test('reconcileUnattachedClaimSessions considers ONLY landed tasks (ADR 0120 unchanged)', async () => {
146
+ const { queries } = await withFakeTx(
147
+ (s) => (/FROM claims c JOIN tasks t/.test(s) ? { rows: [] } : null),
148
+ () => db.reconcileUnattachedClaimSessions({ limit: 5 })
149
+ );
150
+ const scan = find(queries, /FROM claims c JOIN tasks t/);
151
+ assert.ok(scan, 'the attribution sweep must scan claims joined to tasks');
152
+ assert.ok(/t\.status = 'shipped'/.test(norm(scan)),
153
+ "the sweep must require status='shipped' — never attribute-and-pay unlanded work");
154
+ assert.equal(scan.params[0], 5, 'the caller-supplied limit is honoured');
155
+ });
156
+
157
+ // The guard that keeps a safety net from becoming an unauthorised mass payout.
158
+ // Without the window, the first sweep on cloudbongos.com would have retro-linked
159
+ // 1383 tasks / 1157 sessions for a single builder and paid them on the same tick.
160
+ await test('the sweep is WINDOWED — it can never become a retroactive mass backfill', async () => {
161
+ const { queries } = await withFakeTx(
162
+ (s) => (/FROM claims c JOIN tasks t/.test(s) ? { rows: [] } : null),
163
+ () => db.reconcileUnattachedClaimSessions({ limit: 5 })
164
+ );
165
+ const scan = find(queries, /FROM claims c JOIN tasks t/);
166
+ assert.ok(/shipped_at, t\.updated_at\) >= now\(\) - /.test(norm(scan)),
167
+ 'the sweep MUST bound itself to recently-landed tasks — historical back-pay is a deliberate Archon decision, not a poller side effect');
168
+ assert.equal(scan.params[1], '7', 'conservative default lookback');
169
+ });
170
+
171
+ await test('lookbackDays is clamped to a sane range (no 0-day no-op, no 10-year backfill)', async () => {
172
+ for (const [given, expected] of [[0, '1'], [-5, '1'], [99999, '365'], ['junk', '7']]) {
173
+ const { queries } = await withFakeTx(
174
+ (s) => (/FROM claims c JOIN tasks t/.test(s) ? { rows: [] } : null),
175
+ () => db.reconcileUnattachedClaimSessions({ limit: 5, lookbackDays: given })
176
+ );
177
+ assert.equal(find(queries, /FROM claims c JOIN tasks t/).params[1], expected,
178
+ `lookbackDays ${given} must clamp to ${expected}`);
179
+ }
180
+ });
181
+
182
+ await test('a non-finite task id is a clean no-op (no SQL, no reward)', async () => {
183
+ const { result, queries } = await withFakeTx(() => null, () => db.awardDeferredSessionRewardForTask('nope'));
184
+ assert.equal(queries.length, 0, 'a garbage id must not reach the DB');
185
+ assert.deepEqual(result, { sessions: 0, awarded: 0, attached: 0 });
186
+ });
187
+
188
+ summary();
@@ -224,8 +224,10 @@ await test('awardDeferredSessionRewardForTask: no session shipped this task →
224
224
 
225
225
  await test('awardDeferredSessionRewardForTask: non-numeric id → immediate no-op (no DB touch)', async () => {
226
226
  // No withTx patch — a bad id must return before opening a transaction.
227
+ // `attached` joined the shape in task 1002964 (the claim-based attribution
228
+ // recovery that now runs ahead of the payable-session lookup).
227
229
  const out = await db.awardDeferredSessionRewardForTask('not-a-number');
228
- assert.deepEqual(out, { sessions: 0, awarded: 0 });
230
+ assert.deepEqual(out, { sessions: 0, awarded: 0, attached: 0 });
229
231
  });
230
232
 
231
233
  // =========================================================================