@bongos/core 1.19.153 → 1.19.155

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.153",
6
- "core_contract": "1.19.153",
7
- "source_commit": "1c17819c21e41fd19e87ef09db8074217573cfbc",
5
+ "core_version": "1.19.155",
6
+ "core_contract": "1.19.155",
7
+ "source_commit": "64b481f3953b847b3950e606203102e1d130fbc9",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-16T15:45:14.904Z",
9
+ "built_at": "2026-08-16T16:05:24.376Z",
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": 1255,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1579,
20
- "tree_sha256": "37dfe72cba7625d785b1ef4871d14fa1d8427c10d27429fd2fe7bccfe4eb492f",
19
+ "file_count": 1580,
20
+ "tree_sha256": "6da1e49fcc1c83ee83beaf54277f2ab817dba9dea35185de47e0528a4e9e53aa",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -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": "b4eb6ecd20ea8082543a6cc794599b8c4fa46fc6ba15b90823fd8ad98305de05"
4190
+ "sha256": "356b00e692fa069d4f14d94317d0c126ef5c35879491be3fb41ea7c8536d3059"
4191
4191
  },
4192
4192
  {
4193
4193
  "path": "package.json",
4194
4194
  "mode": "0000644",
4195
- "sha256": "36635f9e27784771de1f3630b1a8d304e66cdba1be334a1aeefdf0bdba6f1584"
4195
+ "sha256": "62448d273d09db942d8c9fa123c8a6238070ca9bf754a6ecf0ea664d2c170df5"
4196
4196
  },
4197
4197
  {
4198
4198
  "path": "public-docs/index.html",
@@ -4877,12 +4877,12 @@
4877
4877
  {
4878
4878
  "path": "scripts/gds/gen-repo-map.js",
4879
4879
  "mode": "0000644",
4880
- "sha256": "5b5a3a160dcf65745e052bb08a85a707cdae67ecab2228487fce15872783a673"
4880
+ "sha256": "138c53e17099ef3d5f8809f1be6311e07c4bdd4bd7dd6def2c7c6c57a2e8ba67"
4881
4881
  },
4882
4882
  {
4883
4883
  "path": "scripts/gds/gen-session-index.js",
4884
4884
  "mode": "0000644",
4885
- "sha256": "93b76f22c490a28cdf64a62795c3e7f67bf3b86d2a5331d8d4eed2550a2d89db"
4885
+ "sha256": "c7bdbd7f7a4d2647be17ad37b540afc35913db80d121000346256d57c065f9fa"
4886
4886
  },
4887
4887
  {
4888
4888
  "path": "scripts/gds/git-merge-regen.js",
@@ -5782,7 +5782,7 @@
5782
5782
  {
5783
5783
  "path": "src/module-api.js",
5784
5784
  "mode": "0000644",
5785
- "sha256": "1be644246d1a0e4b152aecd23ce219d4315c5aca5b911b5b4ca7738ac92d9bcf"
5785
+ "sha256": "bc766f15de530f14ccf76f0b280e58f4ec594efebea6570744012fc5483b98d4"
5786
5786
  },
5787
5787
  {
5788
5788
  "path": "src/module-loader/catalog.js",
@@ -7457,7 +7457,7 @@
7457
7457
  {
7458
7458
  "path": "tests/repo_map.mjs",
7459
7459
  "mode": "0000644",
7460
- "sha256": "b88208852062b4e8c8efcb44380e13205cd4d5ece7eed4e6c340a0ea969b33a9"
7460
+ "sha256": "12fe5a30bfebce58aa179767590df53cc5f220a1f7257bded08aef94fbe43507"
7461
7461
  },
7462
7462
  {
7463
7463
  "path": "tests/reward_gate.mjs",
@@ -7579,6 +7579,11 @@
7579
7579
  "mode": "0000644",
7580
7580
  "sha256": "921c24a356d0fc1f398563bab3e8bb6f0831919a6954d86b6d74fdc60ff3b5f9"
7581
7581
  },
7582
+ {
7583
+ "path": "tests/session_attach_from_claim.mjs",
7584
+ "mode": "0000644",
7585
+ "sha256": "e76a3baf5fb2cdee08ffe8d80c55630b5ab4f708046f3c63c07c480926ccda31"
7586
+ },
7582
7587
  {
7583
7588
  "path": "tests/session_caps.mjs",
7584
7589
  "mode": "0000644",
@@ -7612,7 +7617,7 @@
7612
7617
  {
7613
7618
  "path": "tests/session_reward_on_land.mjs",
7614
7619
  "mode": "0000644",
7615
- "sha256": "62d0b356029aca4ea307957951aba7ec677598c152ce68caee5001c6c36f9323"
7620
+ "sha256": "52be7bd7afd17f7f7d1c93efb9be32902d08062cd65f35e3e3709a0e3669050f"
7616
7621
  },
7617
7622
  {
7618
7623
  "path": "tests/session_reward_reconcile.mjs",
@@ -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.153",
3
+ "version": "1.19.155",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.153",
9
+ "version": "1.19.155",
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.153",
3
+ "version": "1.19.155",
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",
@@ -24,6 +24,9 @@
24
24
  // node scripts/gds/gen-repo-map.js --check # write nothing; exit 2 if EITHER bucket would change
25
25
  // node scripts/gds/gen-repo-map.js --check-block # exit 2 only if a TRACKED nested block would change (the CI gate)
26
26
  // node scripts/gds/gen-repo-map.js --check-whole # exit 2 only if docs/repo-map.md would change
27
+ // node scripts/gds/gen-repo-map.js --whole-only # WRITE docs/repo-map.md only; never touch the
28
+ // # tracked nested blocks (what a SPARSE dev-box
29
+ // # checkout must use — task 1002962)
27
30
  // docs/repo-map.md is GITIGNORED + regenerated at deploy/box-fetch since ADR 0110 (task 1926);
28
31
  // only the nested-doc symbol blocks stay tracked, so CI gates the block bucket, not the whole file.
29
32
  // node scripts/gds/gen-repo-map.js --json # print the extracted model as JSON (debug)
@@ -463,6 +466,21 @@ function main() {
463
466
  const checkAll = process.argv.includes('--check');
464
467
  const checkWhole = process.argv.includes('--check-whole');
465
468
  const checkBlock = process.argv.includes('--check-block');
469
+ // WRITE-side bucket selection, the sibling of the --check-* flags above (task
470
+ // 1002962). --whole-only regenerates the GITIGNORED docs/repo-map.md and leaves
471
+ // the TRACKED nested blocks alone.
472
+ //
473
+ // WHY a caller ever needs this: a dev box is a SPARSE checkout (box-access.js
474
+ // materializes only the claim's module cone). The block ordering is
475
+ // `b.refs - a.refs || name`, and `refs` counts references across the VISIBLE
476
+ // corpus — so a box with 7 of 20 modules computes different counts and emits a
477
+ // legitimately different block. Written into a tracked file that dirties the
478
+ // working tree, and box-source-fetch.sh's NEXT `git pull` aborts with "local
479
+ // changes would be overwritten" — a silent, permanent sync deadlock (one box sat
480
+ // 257 commits behind for three days). A box CONSUMES these blocks; they arrive
481
+ // correct with the pull, generated by CI from the whole tree. Only a full
482
+ // checkout may produce them.
483
+ const wholeOnly = process.argv.includes('--whole-only');
466
484
  const model = buildModel();
467
485
 
468
486
  if (process.argv.includes('--json')) {
@@ -478,8 +496,9 @@ function main() {
478
496
  const mapCur = fs.existsSync(mapPath) ? fs.readFileSync(mapPath, 'utf8') : '';
479
497
  if (!sameContent(mapCur, mapNext)) writes.push({ full: mapPath, content: mapNext, rel: 'docs/repo-map.md', bucket: 'whole' });
480
498
 
481
- // 2) nested CLAUDE.md generated blocks (tracked; the HARD-gated bucket)
482
- for (const dir of NESTED_DIRS) {
499
+ // 2) nested CLAUDE.md generated blocks (tracked; the HARD-gated bucket).
500
+ // Skipped entirely under --whole-only — see the flag's rationale above.
501
+ for (const dir of (wholeOnly ? [] : NESTED_DIRS)) {
483
502
  const docPath = path.join(REPO_ROOT, dir, 'CLAUDE.md');
484
503
  if (!fs.existsSync(docPath)) {
485
504
  console.error(`gen-repo-map: WARN nested doc missing, skipping: ${dir}/CLAUDE.md`);
@@ -25,6 +25,8 @@
25
25
  // node scripts/gds/gen-session-index.js --check # write nothing; exit 2 if EITHER bucket would change
26
26
  // node scripts/gds/gen-session-index.js --check-block # exit 2 only if the tracked CLAUDE.md §13 snippet drifts (CI gate)
27
27
  // node scripts/gds/gen-session-index.js --check-whole # exit 2 only if docs/session-log-index.md drifts
28
+ // node scripts/gds/gen-session-index.js --whole-only # WRITE the index only; never touch the tracked
29
+ // # CLAUDE.md snippet (sparse dev-box use; task 1002962)
28
30
  // docs/session-log-index.md is GITIGNORED + regenerated at deploy/box-fetch since ADR 0110 (task 1926).
29
31
  // node scripts/gds/gen-session-index.js --json # print the parsed entries as JSON (debug)
30
32
 
@@ -180,7 +182,15 @@ function main() {
180
182
  process.stdout.write(JSON.stringify(collectEntries(), null, 2) + '\n');
181
183
  return;
182
184
  }
183
- const { writes } = computeWrites();
185
+ // WRITE-side bucket selection — the sibling of the --check-* flags (task 1002962,
186
+ // the gen-repo-map --whole-only twin). A dev box is a SPARSE checkout, so writing
187
+ // the TRACKED bucket there dirties the tree and the next `git pull` in
188
+ // box-source-fetch.sh aborts on "local changes would be overwritten" — a silent,
189
+ // permanent sync deadlock. A box regenerates only the gitignored artifact; the
190
+ // tracked CLAUDE.md §13 snippet arrives with the pull, generated from the whole tree.
191
+ const wholeOnly = process.argv.includes('--whole-only');
192
+ const all = computeWrites().writes;
193
+ const writes = wholeOnly ? all.filter((w) => w.bucket === 'whole') : all;
184
194
  if (checkAll || checkWhole || checkBlock) {
185
195
  const wantBlock = checkAll || checkBlock;
186
196
  const wantWhole = checkAll || checkWhole;
package/src/module-api.js CHANGED
@@ -733,8 +733,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
733
733
  // landed since 1.19.151 with no explicit bump. run 31955923444. (task 1002620)
734
734
  // 1.19.153 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
735
735
  // landed since 1.19.152 with no explicit bump. run 31956560292. (task 1002620)
736
+ // 1.19.154 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
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)
736
740
  // ---------------------------------------------------------------------------
737
- const CORE_VERSION = '1.19.153'; // CI auto-patch carrier (ADR 0161); see changelog above
741
+ const CORE_VERSION = '1.19.155'; // CI auto-patch carrier (ADR 0161); see changelog above
738
742
 
739
743
  // A namespaced logger so a module's log lines are attributable + consistent.
740
744
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -2,7 +2,7 @@
2
2
  import assert from 'node:assert/strict';
3
3
  import { test } from 'node:test';
4
4
  import { execFileSync } from 'node:child_process';
5
- import { readFileSync } from 'node:fs';
5
+ import { readFileSync, writeFileSync } from 'node:fs';
6
6
  import { createRequire } from 'node:module';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import path from 'node:path';
@@ -94,3 +94,42 @@ test('extractor parses functions, classes, arrow consts, and module.exports', ()
94
94
  const apiCall = model.allSyms.find((s) => s.name === 'apiCall' && s.file === 'scripts/gds/cli-lib.js');
95
95
  assert.ok(apiCall && apiCall.exported && apiCall.kind === 'fn', 'exported arrow/fn const detected with export flag');
96
96
  });
97
+
98
+ test('--whole-only never writes a TRACKED nested block (the sparse-box deadlock guard, task 1002962)', () => {
99
+ // A dev box is a SPARSE checkout — box-access.js materializes only the claim's
100
+ // module cone. The block ordering is `b.refs - a.refs || name` and `refs` counts
101
+ // references across the VISIBLE corpus, so a box with 7 of 20 modules emits a
102
+ // legitimately different block. box-source-fetch.sh used to write it, dirtying
103
+ // tracked files, and the NEXT `git pull` aborted on "local changes would be
104
+ // overwritten" — a silent permanent sync deadlock (one box sat 257 commits behind
105
+ // for three days). A box CONSUMES these blocks; only a full checkout may write them.
106
+ //
107
+ // Asserted on CONTENT, not on the log line: dirty a tracked block, run --whole-only,
108
+ // and require the block to come back byte-identical. A regression that re-enabled the
109
+ // bucket would rewrite it and fail here even if the summary line still looked right.
110
+ const tracked = path.join(ROOT, 'scripts', 'gds', 'CLAUDE.md');
111
+ const pristine = readFileSync(tracked, 'utf8');
112
+ const drifted = `${pristine}\n<!-- simulated sparse-checkout drift -->\n`;
113
+ writeFileSync(tracked, drifted);
114
+ try {
115
+ const r = run(['--whole-only']);
116
+ assert.equal(r.code, 0, `--whole-only should exit 0, got ${r.code} ${r.stderr || ''}`);
117
+ assert.equal(
118
+ readFileSync(tracked, 'utf8'),
119
+ drifted,
120
+ '--whole-only rewrote a tracked nested block — this is the deadlock that stranded a box 257 commits behind',
121
+ );
122
+ assert.ok(!r.stdout.includes('CLAUDE.md'), `--whole-only must not report a nested-doc write; got: ${r.stdout.trim()}`);
123
+ } finally {
124
+ writeFileSync(tracked, pristine);
125
+ }
126
+ });
127
+
128
+ test('the default (no-flag) run still owns BOTH buckets', () => {
129
+ // --whole-only must be strictly opt-in: CI and a full-checkout dev run keep
130
+ // regenerating the tracked blocks, which is what makes them trustworthy in git.
131
+ const src = readFileSync(GEN, 'utf8');
132
+ assert.match(src, /wholeOnly \? \[\] : NESTED_DIRS/, 'bucket skip should be gated on the flag, not unconditional');
133
+ // and on a clean tree the tracked bucket is genuinely current
134
+ assert.equal(run(['--check-block']).code, 0, 'tracked blocks must be current on a clean checkout');
135
+ });
@@ -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
  // =========================================================================