@bongos/core 1.24.6 → 1.24.7

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.
@@ -5,7 +5,7 @@
5
5
  "adr": "0341 D3",
6
6
  "reread": "node scripts/bongos/page-reader.js --stale",
7
7
  "how": ["Generated by node scripts/bongos/page-reader.js (BV2.TW04, task 1004315; ADR 0341 D3). Do not hand-edit.","One entry per page of docs/page-inventory.json. Lines are in page order: the default state first, and a line only another state shows sits where that state shows it, with its state named.","placement: placed = a docs/copy-registry.json row renders it (file, line, string_id); shared = shell text every page of the surface carries (top bar, sidebar, footer), with its row when the registry has one; unplaced = the registry cannot place it (a rewrite becomes an engineer task, ADR 0341 D4).","text keeps {…} where the registry row has a hole, or where a value the page read from its API was filled in. A string that is only such data is counted under omitted.data, not listed.","reading_hash is over the non-shared lines (key, section, text); files_hash is over the page's inventory files. --check compares files_hash only and never opens a browser.","Rendered through the hall-preview harness (hall) and the ui-design kit stub (landing, status), at 1280x800 with the clock pinned, so a reading is the fixture world's page."],
8
- "counts": {"pages":48,"lines":5464,"placed":1686,"shared":427,"unplaced":3351,"omitted_data":3732},
8
+ "counts": {"pages":48,"lines":5464,"placed":1687,"shared":427,"unplaced":3350,"omitted_data":3732},
9
9
  "pages": [
10
10
  {
11
11
  "id": "builders:ai-studio",
@@ -2595,9 +2595,9 @@
2595
2595
  "surface": "builders",
2596
2596
  "title": "Project settings",
2597
2597
  "states_read": ["default"],
2598
- "files_hash": "d2c64d498fd8dd17",
2599
- "reading_hash": "b95b4a939af237c6",
2600
- "counts": {"lines":38,"placed":19,"shared":8,"unplaced":11},
2598
+ "files_hash": "39656ae681066cb4",
2599
+ "reading_hash": "4f7a29f2c2f0ad7c",
2600
+ "counts": {"lines":38,"placed":20,"shared":8,"unplaced":10},
2601
2601
  "omitted": {"data":31},
2602
2602
  "notes": [],
2603
2603
  "lines": [
@@ -2609,7 +2609,7 @@
2609
2609
  {"key":"L0006","section":"top bar","text":"Artistry","placement":"shared"},
2610
2610
  {"key":"L0007","section":"top bar","text":"Ideating","placement":"shared"},
2611
2611
  {"key":"L0008","section":"The fundamentals","text":"The fundamentals","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":39,"string_id":"cb7c7879eeba"},
2612
- {"key":"L0009","section":"The fundamentals","text":"Six things every project settles. Three of them are knobs you can turn here; the other three are decided in committed configuration, and each says which file decides it.","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":40,"string_id":"ce994c34804e"},
2612
+ {"key":"L0009","section":"The fundamentals","text":"Seven things every project settles. Four of them are knobs you can turn here; the other three are decided in committed configuration, and each says which file decides it.","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":40,"string_id":"f7da8748453d"},
2613
2613
  {"key":"L0010","section":"The fundamentals","text":"{…} and {…}","placement":"placed","file":"modules/hall-ui/public/profile-nudge.js","line":77,"string_id":"56b00d773232","occurrences":9},
2614
2614
  {"key":"L0011","section":"The fundamentals","text":"How tasks ship","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":136,"string_id":"76f344dd4727"},
2615
2615
  {"key":"L0012","section":"The fundamentals","text":"Save","placement":"unplaced","occurrences":4},
@@ -2617,14 +2617,14 @@
2617
2617
  {"key":"L0014","section":"The fundamentals","text":"Raise after","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":115,"string_id":"4de232a05888"},
2618
2618
  {"key":"L0015","section":"The fundamentals","text":"days ({…}–{…})","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":115,"string_id":"0e2bef756977"},
2619
2619
  {"key":"L0016","section":"The fundamentals","text":"offered","placement":"unplaced","occurrences":2},
2620
- {"key":"L0017","section":"The fundamentals","text":"New category label","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":164,"string_id":"87f09d983f6c","attr":"placeholder"},
2621
- {"key":"L0018","section":"The fundamentals","text":"Add","placement":"unplaced"},
2620
+ {"key":"L0017","section":"The fundamentals","text":"New category label","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":193,"string_id":"87f09d983f6c","attr":"placeholder"},
2621
+ {"key":"L0018","section":"The fundamentals","text":"Add","placement":"placed","file":"modules/hall-ui/public/ai-studio.js","line":469,"string_id":"e4da002ae135"},
2622
2622
  {"key":"L0019","section":"The fundamentals","text":"committed config","placement":"unplaced"},
2623
2623
  {"key":"L0020","section":"The fundamentals","text":"not yet real","placement":"unplaced"},
2624
2624
  {"key":"L0021","section":"The fundamentals","text":"not available ({…})","placement":"unplaced"},
2625
2625
  {"key":"L0022","section":"Danger zone","text":"Danger zone","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":56,"string_id":"13b22e48d9fb"},
2626
- {"key":"L0023","section":"Danger zone","text":"This project is hosted on {…}, and that is where it is taken offline or removed. Both links below open this project’s page there.","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":300,"string_id":"e97b2d549f10"},
2627
- {"key":"L0024","section":"Danger zone","text":"Manage this project on {…}","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":301,"string_id":"4e261fd15bd5"},
2626
+ {"key":"L0023","section":"Danger zone","text":"This project is hosted on {…}, and that is where it is taken offline or removed. Both links below open this project’s page there.","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":350,"string_id":"e97b2d549f10"},
2627
+ {"key":"L0024","section":"Danger zone","text":"Manage this project on {…}","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":351,"string_id":"4e261fd15bd5"},
2628
2628
  {"key":"L0025","section":"Danger zone","text":"Take it offline","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":59,"string_id":"da9da6e866bd"},
2629
2629
  {"key":"L0026","section":"Danger zone","text":"— stops the project and its nightly backups but keeps everything: its database, its backups and your repository on GitHub. You can bring it back.","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":59,"string_id":"61ebc5d684fd"},
2630
2630
  {"key":"L0027","section":"Danger zone","text":"Disconnect from Bongos entirely","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":60,"string_id":"d3efd513694b"},
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Project settings — {{worldName}}</title>
7
- <meta name="description" content="The fundamentals of this project in one place — how tasks ship, the stale timer, the category set, how credits are calculated, why credits are not equity, and whether a credit-weighted pool can pay out.">
7
+ <meta name="description" content="The fundamentals of this project in one place — how tasks ship, how many tweak rounds a month, the stale timer, the category set, how credits are calculated, why credits are not equity, and whether a credit-weighted pool can pay out.">
8
8
  <link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
9
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -38,11 +38,11 @@
38
38
  <section class="scroll" id="ps-body" aria-labelledby="ps-body-h" hidden>
39
39
  <h2 class="scroll__h" id="ps-body-h">The fundamentals</h2>
40
40
  <p class="scroll__lede">
41
- Six things every project settles. Three of them are knobs you can turn here;
41
+ Seven things every project settles. Four of them are knobs you can turn here;
42
42
  the other three are decided in committed configuration, and each says which
43
43
  file decides it.
44
44
  </p>
45
- <!-- One mount, six rows, written by project-settings.js. The rows are NOT
45
+ <!-- One mount, seven rows, written by project-settings.js. The rows are NOT
46
46
  authored in this file: each carries a value plus the provenance the
47
47
  server resolved it through, and markup that could drift from that is the
48
48
  thing this page exists to end. -->
@@ -76,6 +76,6 @@
76
76
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
77
77
  <script src="/builders/palette.js" defer></script>
78
78
  <script src="/builders/hall-kit.js"></script>
79
- <script src="/builders/project-settings.js?v=2026-10-08-ship-mode"></script>
79
+ <script src="/builders/project-settings.js?v=2026-10-29-tweak-cap"></script>
80
80
  </body>
81
81
  </html>
@@ -1,4 +1,4 @@
1
- // project-settings.js — the five fundamentals of this project, each with the place
1
+ // project-settings.js — the seven fundamentals of this project, each with the place
2
2
  // its value came from (task 1003280, goal 1000072, criterion legibility-project-settings).
3
3
  //
4
4
  // WHAT THIS PAGE IS FOR. Goal 1000072's thesis: the project is legible to Claude and
@@ -148,6 +148,35 @@
148
148
  </div>`;
149
149
  }
150
150
 
151
+ // The monthly tweak-round cap (task 1005003): the project's number, then one box
152
+ // per builder rank. The ranks and the bound come from the server; a blank rank box
153
+ // means that rank follows the project's number.
154
+ function tweakCapBodyHtml(row) {
155
+ const v = row.value || {};
156
+ const ranks = Array.isArray(v.ranks) ? v.ranks : [];
157
+ const max = escapeHtml(String(v.max));
158
+ const invalid = row.invalid
159
+ ? `<p class="ps-invalid">A configured value could not be used: <code>${escapeHtml(String(row.invalid.raw))}</code>
160
+ — ${escapeHtml(String(row.invalid.reason))}. The limit above is the fallback that is actually in force.</p>`
161
+ : '';
162
+ return `
163
+ ${invalid}
164
+ <div class="ps-controls">
165
+ <label for="ps-tweak-cap">Project limit</label>
166
+ <input type="number" id="ps-tweak-cap" min="1" max="${max}" step="1" value="${escapeHtml(String(v.cap))}">
167
+ <span>rounds a month (1–${max})</span>
168
+ </div>
169
+ ${ranks.map((r) => `
170
+ <div class="ps-controls">
171
+ <label for="ps-tweak-rank-${escapeHtml(String(r.rank))}">${escapeHtml(String(r.rank))}</label>
172
+ <input type="number" class="ps-tweak-rank" id="ps-tweak-rank-${escapeHtml(String(r.rank))}" data-rank="${escapeHtml(String(r.rank))}"
173
+ min="1" max="${max}" step="1" value="${r.cap == null ? '' : escapeHtml(String(r.cap))}" placeholder="project limit">
174
+ </div>`).join('')}
175
+ <div class="ps-controls">
176
+ <button type="button" class="btn" id="ps-tweak-save">Save</button>
177
+ </div>`;
178
+ }
179
+
151
180
  function catsBodyHtml(row) {
152
181
  const v = row.value || {};
153
182
  const cats = Array.isArray(v.categories) ? v.categories : [];
@@ -177,6 +206,7 @@
177
206
  const mount = $('ps-rows');
178
207
  mount.innerHTML = `<ul class="ov-rows">${rows.map((row) => {
179
208
  if (row.key === 'ship_mode' && row.editable) return rowHtml(row, shipBodyHtml(row));
209
+ if (row.key === 'tweak_round_cap' && row.editable) return rowHtml(row, tweakCapBodyHtml(row));
180
210
  if (row.key === 'rot_timer' && row.editable) return rowHtml(row, rotBodyHtml(row));
181
211
  if (row.key === 'work_categories' && row.editable) return rowHtml(row, catsBodyHtml(row));
182
212
  return rowHtml(row, '');
@@ -210,6 +240,26 @@
210
240
  });
211
241
  }
212
242
 
243
+ const tweakSave = $('ps-tweak-save');
244
+ if (tweakSave) {
245
+ tweakSave.addEventListener('click', async () => {
246
+ const rankCaps = {};
247
+ document.querySelectorAll('.ps-tweak-rank').forEach((el) => {
248
+ const raw = String(el.value || '').trim();
249
+ rankCaps[el.getAttribute('data-rank')] = raw === '' ? null : Number(raw);
250
+ });
251
+ setBusy(true);
252
+ try {
253
+ const out = await sendJson('PATCH', '/project-settings/tweak-autopickup', { cap: Number($('ps-tweak-cap').value), rank_caps: rankCaps });
254
+ toast(`Monthly tweak rounds: ${out.cap} for the project.`);
255
+ await load();
256
+ } catch (err) {
257
+ toast(err.message || 'Could not save the limit.');
258
+ setBusy(false);
259
+ }
260
+ });
261
+ }
262
+
213
263
  const rotSave = $('ps-rot-save');
214
264
  if (rotSave) {
215
265
  rotSave.addEventListener('click', async () => {
@@ -56,11 +56,26 @@ async function countForRound(exec, taskId) {
56
56
  return Number(rows[0] && rows[0].n) || 0;
57
57
  }
58
58
 
59
- // autoPickupRefusal(client, task, settings) — null when the marked claim may
59
+ // effectiveCap(settings, rank) — { cap, from: 'rank' | 'project' } (task 1005003).
60
+ // The claimer's rank's own cap when the project set one, else the project cap. The
61
+ // cap is measured against the project's monthly count either way: a rank cap says
62
+ // how far into the month a runner of that rank may still pick up, not a separate
63
+ // allowance per builder, so the ledger count stays one project-wide number. Pure.
64
+ function effectiveCap(settings, rank) {
65
+ const s = settings || {};
66
+ const caps = s.rank_caps || {};
67
+ if (rank && Object.prototype.hasOwnProperty.call(caps, rank) && Number.isInteger(caps[rank])) {
68
+ return { cap: caps[rank], from: 'rank' };
69
+ }
70
+ return { cap: s.cap, from: 'project' };
71
+ }
72
+
73
+ // autoPickupRefusal(client, task, settings, rank) — null when the marked claim may
60
74
  // proceed, else the refusal object claimTask throws. `settings` is the resolved
61
- // { mode, cap }, read BEFORE the transaction opens (the claimPermissionsFor
62
- // precedent: a settings read is not part of the claim's consistency).
63
- async function autoPickupRefusal(client, task, settings) {
75
+ // { mode, cap, rank_caps }, read BEFORE the transaction opens (the
76
+ // claimPermissionsFor precedent: a settings read is not part of the claim's
77
+ // consistency). `rank` is the claimer's rank, read inside it.
78
+ async function autoPickupRefusal(client, task, settings, rank = null) {
64
79
  if (!task || task.source !== PAGE_TWEAK_SOURCE) {
65
80
  return { code: 'AUTO_PICKUP_NOT_A_TWEAK', message: 'Only a page tweak round can be claimed with the auto_pickup mark.' };
66
81
  }
@@ -77,11 +92,12 @@ async function autoPickupRefusal(client, task, settings) {
77
92
  };
78
93
  }
79
94
  const used = await countThisMonth(client);
80
- if (used >= s.cap) {
81
- return {
82
- code: 'AUTO_PICKUP_CAP_REACHED', used, cap: s.cap,
83
- message: `Auto-pickup has used this month's ${s.cap} rounds, so submitted pages wait for a person until next month.`,
84
- };
95
+ const { cap, from } = effectiveCap(s, rank);
96
+ if (used >= cap) {
97
+ const message = from === 'rank'
98
+ ? `This month's auto-pickups have reached ${cap}, the limit this project sets for a ${rank} builder's runner, so submitted pages wait for a person until next month.`
99
+ : `Auto-pickup has used this month's ${cap} rounds, so submitted pages wait for a person until next month.`;
100
+ return { code: 'AUTO_PICKUP_CAP_REACHED', used, cap, cap_from: from, message };
85
101
  }
86
102
  return null;
87
103
  }
@@ -90,5 +106,6 @@ module.exports = {
90
106
  LOCK_KEY,
91
107
  MAX_AUTO_APPLIES_PER_ROUND,
92
108
  countThisMonth,
109
+ effectiveCap,
93
110
  autoPickupRefusal,
94
111
  };
@@ -374,7 +374,7 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
374
374
  // limit are counted under a project-wide advisory lock that this transaction
375
375
  // holds through the insert below, so two runners cannot both take the last slot.
376
376
  if (autoPickup) {
377
- const refusal = await autoPickupRefusal(client, task, autoPickupSettings);
377
+ const refusal = await autoPickupRefusal(client, task, autoPickupSettings, builderRank);
378
378
  if (refusal) throw refusal;
379
379
  }
380
380
 
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // modules/lifecycle/routes/project-settings.js — the five fundamentals of a project,
3
+ // modules/lifecycle/routes/project-settings.js — the seven fundamentals of a project,
4
4
  // in one read, each carrying WHERE its value came from (task 1003280, goal 1000072,
5
5
  // criterion legibility-project-settings).
6
6
  //
@@ -60,6 +60,33 @@ function shipModeRow(resolved) {
60
60
  };
61
61
  }
62
62
 
63
+ // --- the monthly tweak-round cap (task 1005003) -----------------------------
64
+ //
65
+ // The project cap and one optional cap per builder rank, editable on the settings
66
+ // page. The ranks come from the kernel's live ladder, never re-typed here or on the
67
+ // page; a rank with no number follows the project cap.
68
+ function tweakCapRow(resolved) {
69
+ const r = resolved || {};
70
+ const rankCaps = r.rank_caps || {};
71
+ return {
72
+ key: 'tweak_round_cap',
73
+ title: 'Monthly tweak rounds',
74
+ editable: true,
75
+ state: 'live',
76
+ value: {
77
+ cap: r.cap,
78
+ max: api.projectSettings.MAX_AUTOPICKUP_CAP,
79
+ ranks: api.LIVE_RANK_LADDER.map((rank) => ({ rank, cap: Object.prototype.hasOwnProperty.call(rankCaps, rank) ? rankCaps[rank] : null })),
80
+ },
81
+ sentence: `Tweak auto-pickup may apply up to ${r.cap} submitted pages a month. A builder rank can have its own limit; `
82
+ + 'a rank left blank follows the project limit. When the limit is reached, pages wait for a person.',
83
+ source: r.source ? r.source.cap : 'default',
84
+ // Only a cap value this row draws; an unusable on/off switch is not this row's.
85
+ invalid: (r.invalid || []).find((i) => /autopickup_(monthly_cap|rank_cap\.)/.test(String(i && i.where))) || null,
86
+ reads: { file: 'project_settings[\'tweak.autopickup_monthly_cap\', \'tweak.autopickup_rank_cap.<rank>\']', adr: 'ADR 0371 D5' },
87
+ };
88
+ }
89
+
63
90
  // callerHolds(builderId, atom) → true | false | null. null means authority could
64
91
  // not be established (government port missing or the resolver threw); a reader
65
92
  // treats that as unknown, never as a grant.
@@ -264,6 +291,7 @@ module.exports = function buildProjectSettingsRouter() {
264
291
  // category the owner cannot see is one they cannot bring back.
265
292
  const categories = await goalCategory.listCategories({ includeInactive: true });
266
293
  const shipMode = await api.projectSettings.resolveShipMode();
294
+ const tweak = await api.projectSettings.resolveTweakAutopickup();
267
295
 
268
296
  // A settings read is a configuration comparison — a cached one is wrong the
269
297
  // moment somebody turns a knob (the rot.js precedent).
@@ -271,6 +299,7 @@ module.exports = function buildProjectSettingsRouter() {
271
299
  res.json({
272
300
  rows: [
273
301
  shipModeRow(shipMode),
302
+ tweakCapRow(tweak),
274
303
  {
275
304
  key: 'rot_timer',
276
305
  title: 'Stale timer',
@@ -443,14 +472,23 @@ module.exports = function buildProjectSettingsRouter() {
443
472
  //
444
473
  // rank: any signed-in builder. The runner reads it before it tries a round, and
445
474
  // the studio shows the count; it carries no reward internals or file paths.
446
- async function autopickupBody() {
475
+ //
476
+ // cap is the project's; rank_caps the per-rank caps the project set (task
477
+ // 1005003). my_cap is the one that applies to the CALLER's rank, and
478
+ // remaining_this_month is measured against it, so a runner reads the number the
479
+ // claim gate will hold it to.
480
+ async function autopickupBody(rank = null) {
447
481
  const resolved = await api.projectSettings.resolveTweakAutopickup();
448
482
  const used = await autopickup.countThisMonth();
483
+ const mine = autopickup.effectiveCap(resolved, rank);
449
484
  return {
450
485
  mode: resolved.mode,
451
486
  cap: resolved.cap,
487
+ rank_caps: resolved.rank_caps || {},
488
+ my_cap: mine.cap,
489
+ my_cap_from: mine.from,
452
490
  used_this_month: used,
453
- remaining_this_month: Math.max(0, resolved.cap - used),
491
+ remaining_this_month: Math.max(0, mine.cap - used),
454
492
  per_round_limit: autopickup.MAX_AUTO_APPLIES_PER_ROUND,
455
493
  source: resolved.source,
456
494
  invalid: resolved.invalid,
@@ -461,32 +499,54 @@ module.exports = function buildProjectSettingsRouter() {
461
499
  router.get('/project-settings/tweak-autopickup', api.requireBuilder, async (req, res) => {
462
500
  try {
463
501
  res.set('Cache-Control', 'no-store');
464
- res.json(await autopickupBody());
502
+ res.json(await autopickupBody(req.builder && req.builder.rank));
465
503
  } catch (err) {
466
504
  log.error(`GET /project-settings/tweak-autopickup: ${err && err.message}`);
467
505
  res.fail('project_settings_read_failed', { status: 500, message: 'internal error' });
468
506
  }
469
507
  });
470
508
 
471
- // PATCH /project-settings/tweak-autopickup — body { mode?: 'off' | 'on', cap?: n }.
509
+ // PATCH /project-settings/tweak-autopickup — body { mode?: 'off' | 'on', cap?: n,
510
+ // rank_caps?: { <rank>: n | null } }.
472
511
  //
473
512
  // rank: project.settings.manage (metic+, government_010), the same atom as the
474
513
  // other writes on this page. Each field is validated by the resolver's own parser,
475
- // and both are checked before either is written, so a half-valid body writes nothing.
514
+ // and all are checked before any is written, so a half-valid body writes nothing.
515
+ //
516
+ // rank_caps (task 1005003) is a PARTIAL update: a rank named with a number gets
517
+ // that cap, a rank named with null loses its own cap (and follows the project cap
518
+ // again), and a rank not named is left as it was. A rank must be on the live
519
+ // ladder; each number goes through parseAutopickupCap, the project cap's parser.
476
520
  router.patch('/project-settings/tweak-autopickup', api.requireBuilder, api.requirePermission('project.settings.manage'), api.requireActAlone('rules'), async (req, res) => {
477
- if (api.validateOrRespond(req, res, { mode: { type: 'string' }, cap: { type: 'number' } })) return;
521
+ if (api.validateOrRespond(req, res, { mode: { type: 'string' }, cap: { type: 'number' }, rank_caps: { type: 'object' } })) return;
478
522
  const body = req.body || {};
479
- if (body.mode == null && body.cap == null) {
480
- return res.fail('bad_tweak_autopickup', { status: 400, message: 'send mode, cap, or both' });
523
+ if (body.mode == null && body.cap == null && body.rank_caps == null) {
524
+ return res.fail('bad_tweak_autopickup', { status: 400, message: 'send mode, cap, rank_caps, or any of them' });
481
525
  }
482
526
  const mode = body.mode == null ? null : api.projectSettings.parseAutopickup(body.mode);
483
527
  if (mode && !mode.mode) return res.fail('bad_tweak_autopickup', { status: 400, message: `mode: ${mode.reason}` });
484
528
  const cap = body.cap == null ? null : api.projectSettings.parseAutopickupCap(body.cap);
485
529
  if (cap && cap.cap == null) return res.fail('bad_tweak_autopickup', { status: 400, message: `cap: ${cap.reason}` });
530
+ const rankEdits = [];
531
+ for (const [rank, value] of Object.entries(body.rank_caps || {})) {
532
+ if (!api.LIVE_RANK_LADDER.includes(rank)) {
533
+ return res.fail('bad_tweak_autopickup', { status: 400, message: `rank_caps: unknown rank '${rank}' (one of ${api.LIVE_RANK_LADDER.join(' | ')})` });
534
+ }
535
+ if (value === null) { rankEdits.push([rank, null]); continue; }
536
+ const got = api.projectSettings.parseAutopickupCap(value);
537
+ if (got.cap == null) return res.fail('bad_tweak_autopickup', { status: 400, message: `rank_caps.${rank}: ${got.reason}` });
538
+ rankEdits.push([rank, got.cap]);
539
+ }
486
540
  try {
487
541
  if (mode) await api.projectSettings.set(api.projectSettings.KEYS.TWEAK_AUTOPICKUP, mode.mode, req.builder.id);
488
542
  if (cap) await api.projectSettings.set(api.projectSettings.KEYS.TWEAK_AUTOPICKUP_CAP, cap.cap, req.builder.id);
489
- res.json({ ok: true, ...(await autopickupBody()) });
543
+ // One row per rank, each a single upsert: no read-modify-write, so two people
544
+ // editing two ranks at once cannot drop each other's edit. '' clears a rank
545
+ // (the resolver reads an empty value as "no cap of its own").
546
+ for (const [rank, n] of rankEdits) {
547
+ await api.projectSettings.set(`${api.projectSettings.KEYS.TWEAK_AUTOPICKUP_RANK_CAP_PREFIX}${rank}`, n === null ? '' : n, req.builder.id);
548
+ }
549
+ res.json({ ok: true, ...(await autopickupBody(req.builder && req.builder.rank)) });
490
550
  } catch (err) {
491
551
  log.error(`PATCH /project-settings/tweak-autopickup: ${err && err.message}`);
492
552
  res.fail('project_settings_write_failed', { status: 500, message: 'internal error' });
@@ -498,3 +558,4 @@ module.exports = function buildProjectSettingsRouter() {
498
558
 
499
559
  module.exports.settingsDeskRows = settingsDeskRows;
500
560
  module.exports.shipModeRow = shipModeRow;
561
+ module.exports.tweakCapRow = tweakCapRow;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.6",
3
+ "version": "1.24.7",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.6",
9
+ "version": "1.24.7",
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.24.6",
3
+ "version": "1.24.7",
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",
@@ -10871,5 +10871,11 @@
10871
10871
  "id": "1004995",
10872
10872
  "text": "On the account page, people can now link GitHub and Google to one account, both ways, so either one opens the same account with the same credits, ranks and history. They can remove one too, but never their last way in. Two exi"
10873
10873
  }
10874
+ ],
10875
+ "1.24.7": [
10876
+ {
10877
+ "id": "1005003",
10878
+ "text": "The automatic tweak limit is now 200 rounds a month, and a project owner can change it, for the whole project and separately for each builder rank, on the project settings page."
10879
+ }
10874
10880
  ]
10875
10881
  }
@@ -52,6 +52,9 @@ const SETTING_KEYS = {
52
52
  SHIP_MODE: 'ship.mode',
53
53
  TWEAK_AUTOPICKUP: 'tweak.autopickup',
54
54
  TWEAK_AUTOPICKUP_CAP: 'tweak.autopickup_monthly_cap',
55
+ // Per-rank caps (task 1005003): one row per live rank, this prefix + the rank
56
+ // (rankCapKey). An empty value means the rank follows the project cap.
57
+ TWEAK_AUTOPICKUP_RANK_CAP_PREFIX: 'tweak.autopickup_rank_cap.',
55
58
  SPEND_CAP: 'treasury.spend_cap_usd',
56
59
  // Testing new module versions first (task 1003802, ADR 0344 D1): module-testing.js.
57
60
  MODULE_TESTER: 'modules.tester',
@@ -292,9 +295,10 @@ function describeShipMode(resolved) {
292
295
  // (modules/lifecycle/autopickup.js); this file only resolves the numbers.
293
296
  const AUTOPICKUP_MODES = Object.freeze(['off', 'on']);
294
297
  const DEFAULT_AUTOPICKUP = 'off';
295
- // 20 rounds: a grade costs the project a few dollars, so 20 is a bounded, legible
296
- // monthly spend at about the level of one active artist (ADR 0371 D5).
297
- const DEFAULT_AUTOPICKUP_CAP = 20;
298
+ // 200 rounds: the owner's decision on task 1004971 (2026-10-29, Q7), raising ADR
299
+ // 0371 D5's first figure of 20. A project can set its own number, and a cap per
300
+ // builder rank (task 1005003); this is only the answer for a project that has not.
301
+ const DEFAULT_AUTOPICKUP_CAP = 200;
298
302
  // Past 1000 the cap is an off switch typed as a big number; that should be asked
299
303
  // for out loud, the same reasoning as MAX_ROT_DAYS.
300
304
  const MAX_AUTOPICKUP_CAP = 1000;
@@ -318,6 +322,16 @@ function parseAutopickupCap(raw) {
318
322
 
319
323
  // One knob through its parser: { value, source, invalid }. Never throws; an
320
324
  // unreadable database answers the default, the resolveShipMode posture.
325
+ // `row` is the already-read row (or undefined); parseRow never touches the DB.
326
+ function parseRow(key, row, parse, pick, fallback) {
327
+ if (row && row.value != null && row.value !== '') {
328
+ const got = parse(row.value);
329
+ if (pick(got) != null) return { value: pick(got), source: 'project_settings', invalid: null };
330
+ return { value: fallback, source: 'default', invalid: { where: `project_settings['${key}']`, raw: String(row.value), reason: got.reason } };
331
+ }
332
+ return { value: fallback, source: 'default', invalid: null };
333
+ }
334
+
321
335
  async function resolveOne(key, parse, pick, fallback) {
322
336
  let row = null;
323
337
  try {
@@ -326,31 +340,60 @@ async function resolveOne(key, parse, pick, fallback) {
326
340
  logger.error(`project_settings read failed; falling back to the default ${key}: ${err && err.message}`);
327
341
  return { value: fallback, source: 'default', invalid: null };
328
342
  }
329
- if (row && row.value != null && row.value !== '') {
330
- const got = parse(row.value);
331
- if (pick(got) != null) return { value: pick(got), source: 'project_settings', invalid: null };
332
- return { value: fallback, source: 'default', invalid: { where: `project_settings['${key}']`, raw: String(row.value), reason: got.reason } };
333
- }
334
- return { value: fallback, source: 'default', invalid: null };
343
+ return parseRow(key, row, parse, pick, fallback);
344
+ }
345
+
346
+ // rankCapKey(rank) — the row holding one rank's own cap (task 1005003). ONE ROW
347
+ // PER RANK, deliberately: setting or clearing a rank is a single upsert, so two
348
+ // people editing two different ranks at once can never overwrite each other (a
349
+ // shared JSON object would be read-modify-write and lose one edit). An empty
350
+ // value means "no cap of its own" (the null-is-meaningful rule above).
351
+ function rankCapKey(rank) {
352
+ return `${SETTING_KEYS.TWEAK_AUTOPICKUP_RANK_CAP_PREFIX}${rank}`;
335
353
  }
336
354
 
337
- // resolveTweakAutopickup() — { mode, cap, source: { mode, cap }, invalid: [...] }.
338
- // mode is always 'off' or 'on', cap always a usable positive integer.
355
+ // resolveTweakAutopickup() — { mode, cap, rank_caps, source: { mode, cap },
356
+ // invalid: [...] }. mode is always 'off' or 'on', cap always a usable positive
357
+ // integer, rank_caps always an object of live-ladder rank → cap (empty when no rank
358
+ // has its own). Every row is read in ONE query; each rank cap goes through
359
+ // parseAutopickupCap, the project cap's own parser, so the two cannot disagree.
339
360
  async function resolveTweakAutopickup() {
340
- const m = await resolveOne(SETTING_KEYS.TWEAK_AUTOPICKUP, parseAutopickup, (g) => g.mode, DEFAULT_AUTOPICKUP);
341
- const c = await resolveOne(SETTING_KEYS.TWEAK_AUTOPICKUP_CAP, parseAutopickupCap, (g) => g.cap, DEFAULT_AUTOPICKUP_CAP);
361
+ // Lazy: the ladder is the kernel's one copy (tests/rank_tier_single_source.mjs).
362
+ const ladder = require('./db-kernel').LIVE_RANK_LADDER;
363
+ const keys = [SETTING_KEYS.TWEAK_AUTOPICKUP, SETTING_KEYS.TWEAK_AUTOPICKUP_CAP, ...ladder.map(rankCapKey)];
364
+ let byKey = new Map();
365
+ try {
366
+ const { rows } = await pool.query('SELECT key, value FROM project_settings WHERE key = ANY($1::text[])', [keys]);
367
+ byKey = new Map(rows.map((row) => [row.key, row]));
368
+ } catch (err) {
369
+ logger.error(`project_settings read failed; falling back to the default tweak auto-pickup settings: ${err && err.message}`);
370
+ }
371
+ const m = parseRow(SETTING_KEYS.TWEAK_AUTOPICKUP, byKey.get(SETTING_KEYS.TWEAK_AUTOPICKUP), parseAutopickup, (g) => g.mode, DEFAULT_AUTOPICKUP);
372
+ const c = parseRow(SETTING_KEYS.TWEAK_AUTOPICKUP_CAP, byKey.get(SETTING_KEYS.TWEAK_AUTOPICKUP_CAP), parseAutopickupCap, (g) => g.cap, DEFAULT_AUTOPICKUP_CAP);
373
+ const rankCaps = {};
374
+ const invalid = [m.invalid, c.invalid];
375
+ for (const rank of ladder) {
376
+ const r = parseRow(rankCapKey(rank), byKey.get(rankCapKey(rank)), parseAutopickupCap, (g) => g.cap, null);
377
+ if (r.value != null) rankCaps[rank] = r.value;
378
+ invalid.push(r.invalid);
379
+ }
342
380
  return {
343
381
  mode: m.value,
344
382
  cap: c.value,
383
+ rank_caps: rankCaps,
345
384
  source: { mode: m.source, cap: c.source },
346
- invalid: [m.invalid, c.invalid].filter(Boolean),
385
+ invalid: invalid.filter(Boolean),
347
386
  };
348
387
  }
349
388
 
350
389
  function describeTweakAutopickup(resolved) {
351
390
  const r = resolved || {};
352
391
  if (r.mode !== 'on') return 'Off: a submitted page waits until a person runs /tweak for it.';
353
- return `On: a running Autobongos runner applies submitted pages on its own, up to ${r.cap} a month. The artist still approves every one.`;
392
+ const ranks = Object.entries(r.rank_caps || {});
393
+ const byRank = ranks.length
394
+ ? ` Some builder ranks have their own limit: ${ranks.map(([rank, n]) => `${rank} ${n}`).join(', ')}.`
395
+ : '';
396
+ return `On: a running Autobongos runner applies submitted pages on its own, up to ${r.cap} a month.${byRank} The artist still approves every one.`;
354
397
  }
355
398
 
356
399
  // THE SPEND CAP (task 1004712, ADR 0379). The owner's ruling: there is NO spend
@@ -416,6 +459,7 @@ module.exports = {
416
459
  MAX_AUTOPICKUP_CAP,
417
460
  parseAutopickup,
418
461
  parseAutopickupCap,
462
+ rankCapKey,
419
463
  resolveTweakAutopickup,
420
464
  describeTweakAutopickup,
421
465
  MAX_SPEND_CAP_USD,
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.6'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.7'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');