@bongos/core 1.24.6 → 1.24.8

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.
Files changed (52) hide show
  1. package/.bongos-core.json +106 -46
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +13 -1
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0371-auto-pickup-applies-a-submitted-tweak-on-the-runner.md +1 -0
  8. package/docs/api/openapi.json +264 -4
  9. package/docs/api-reference.md +7 -3
  10. package/docs/copy-inventory.md +41 -13
  11. package/docs/copy-registry.json +319 -35
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-inventory.json +40 -4
  14. package/docs/page-readings.json +69 -9
  15. package/modules/government/decision-board.js +5 -0
  16. package/modules/government/decision-rules.js +3 -0
  17. package/modules/government/routes/government.js +2 -0
  18. package/modules/hall-ui/public/market-watch-rules-lib.js +207 -0
  19. package/modules/hall-ui/public/market-watch-rules.css +68 -0
  20. package/modules/hall-ui/public/market-watch-rules.html +54 -0
  21. package/modules/hall-ui/public/market-watch-rules.js +129 -0
  22. package/modules/hall-ui/public/market-watch-rules.states.json +17 -0
  23. package/modules/hall-ui/public/project-settings.html +4 -4
  24. package/modules/hall-ui/public/project-settings.js +51 -1
  25. package/modules/lifecycle/autopickup.js +26 -9
  26. package/modules/lifecycle/db-claims.js +1 -1
  27. package/modules/lifecycle/routes/project-settings.js +71 -10
  28. package/modules/market-watch/matter-acts.js +59 -0
  29. package/modules/market-watch/module.json +5 -4
  30. package/modules/market-watch/proposals.js +214 -0
  31. package/modules/market-watch/research.js +49 -8
  32. package/modules/market-watch/routes/rules.js +202 -0
  33. package/modules/market-watch/rules-logic.js +264 -0
  34. package/modules/market-watch/rules-view.js +165 -0
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/release-notes.json +12 -0
  38. package/scripts/bongos/government-walk.js +18 -2
  39. package/scripts/bongos/run-unit-tests.js +3 -0
  40. package/src/bongos/module-scope-map.js +2 -0
  41. package/src/bongos/project-settings.js +59 -15
  42. package/src/bongos/serve-internal.js +2 -1
  43. package/src/module-api.js +1 -1
  44. package/tests/autopickup_cap.mjs +203 -11
  45. package/tests/government_act_alone_buildings.mjs +3 -0
  46. package/tests/government_decision_rules.mjs +4 -1
  47. package/tests/governor_matter_acts.mjs +5 -1
  48. package/tests/hall_page_gate_map.mjs +3 -0
  49. package/tests/market_watch_module.mjs +1 -0
  50. package/tests/market_watch_rules.mjs +496 -0
  51. package/tests/market_watch_rules_db.mjs +133 -0
  52. package/tests/project_settings_page.mjs +8 -5
@@ -0,0 +1,129 @@
1
+ // market-watch-rules.js — the Market Watch rules page (/builders/market-watch-rules,
2
+ // task 1004941). One read, GET /api/bongos/market-watch/rules; the markup is
3
+ // OTBMarketWatchRules's (market-watch-rules-lib.js). Three writes, each checked again on
4
+ // the server: a member's proposal, the owner's approve or decline, and putting a proposal
5
+ // to the government's vote.
6
+
7
+ (() => {
8
+ 'use strict';
9
+
10
+ const lib = window.OTBMarketWatchRules;
11
+ const esc = (s) => window.OTB.escapeHtml(s);
12
+ const toast = (m) => window.OTB.toast(m);
13
+ const API = '/api/bongos/market-watch';
14
+ const $ = (id) => document.getElementById(id);
15
+
16
+ let view = null;
17
+ let step = 'searches';
18
+ let at = 0;
19
+
20
+ async function send(method, url, body) {
21
+ const r = await fetch(url, {
22
+ method,
23
+ credentials: 'same-origin',
24
+ headers: body ? { 'content-type': 'application/json' } : {},
25
+ body: body ? JSON.stringify(body) : undefined,
26
+ });
27
+ let data = null;
28
+ try { data = await r.json(); } catch (_) { /* no body */ }
29
+ return { ok: r.ok, status: r.status, data };
30
+ }
31
+ const codeOf = (data) => (window.OTB.errCode ? window.OTB.errCode(data) : (data && data.error && data.error.code));
32
+
33
+ function stopNow() {
34
+ const s = lib.stops(view);
35
+ const i = s.findIndex((x) => x.kind === 'now');
36
+ return i === -1 ? Math.max(0, s.length - 1) : i;
37
+ }
38
+
39
+ function render() {
40
+ const stops = lib.stops(view);
41
+ const stop = stops[at] || null;
42
+ $('mwr-slider').innerHTML = lib.sliderHtml(view, at, esc);
43
+ $('mwr-pipe').innerHTML = lib.pipelineHtml(view, { open: step, changed: stop ? stop.changed : [] }, esc);
44
+ $('mwr-panel').innerHTML = lib.panelHtml(view, step, stop, esc);
45
+ $('mwr-cost').innerHTML = lib.costHtml(view.cost, esc);
46
+ const slider = $('mwr-v');
47
+ if (slider) slider.addEventListener('input', () => { at = Number(slider.value); render(); $('mwr-v').focus(); });
48
+ }
49
+
50
+ async function load() {
51
+ const r = await send('GET', `${API}/rules`);
52
+ if (!r.ok) {
53
+ const msg = r.status === 404
54
+ ? 'Market Watch is off on this project. The owner turns it on in Modules.'
55
+ : 'Could not load the watch rules. Try reloading the page.';
56
+ $('mwr-pipe').innerHTML = '';
57
+ $('mwr-panel').innerHTML = `<p class="mwr-empty">${esc(msg)}</p>`;
58
+ return;
59
+ }
60
+ view = r.data;
61
+ if (!view.steps || !view.history || !view.history.length) {
62
+ $('mwr-panel').innerHTML = '<p class="mwr-empty">This project has no watch rules yet.</p>';
63
+ return;
64
+ }
65
+ at = stopNow();
66
+ render();
67
+ }
68
+
69
+ function openForm(btn) {
70
+ const slot = $('mwr-form-slot');
71
+ if (!slot) return;
72
+ if (slot.firstChild) { slot.innerHTML = ''; btn.setAttribute('aria-expanded', 'false'); return; }
73
+ slot.innerHTML = lib.formHtml(view, step, esc);
74
+ btn.setAttribute('aria-expanded', 'true');
75
+ const form = slot.querySelector('form');
76
+ const op = form.querySelector('select[name="op"]');
77
+ if (op) {
78
+ op.addEventListener('change', () => {
79
+ form.querySelectorAll('[data-for]').forEach((el) => { el.hidden = el.dataset.for !== op.value; });
80
+ });
81
+ }
82
+ const first = form.querySelector('input, select, textarea');
83
+ if (first) first.focus();
84
+ }
85
+
86
+ async function submitForm(form) {
87
+ const fields = Object.fromEntries(new FormData(form).entries());
88
+ const body = lib.proposalFromForm(form.dataset.step, fields);
89
+ const btn = form.querySelector('button[type="submit"]');
90
+ btn.disabled = true;
91
+ const r = await send('POST', `${API}/rules/proposals`, body);
92
+ btn.disabled = false;
93
+ if (!r.ok) return toast(lib.errorText(codeOf(r.data)));
94
+ toast('proposal sent');
95
+ await load();
96
+ }
97
+
98
+ async function act(kind, id) {
99
+ const url = kind === 'vote' ? `${API}/proposals/${id}/vote` : `${API}/proposals/${id}/decide`;
100
+ const r = await send('POST', url, kind === 'vote' ? undefined : { decision: kind });
101
+ if (!r.ok) return toast(lib.errorText(codeOf(r.data)));
102
+ if (kind === 'approve') toast(r.data && r.data.version ? `approved · v${r.data.version.version}` : 'approved');
103
+ else toast(kind === 'vote' ? 'sent to a vote' : 'declined');
104
+ await load();
105
+ }
106
+
107
+ document.addEventListener('click', (e) => {
108
+ if (!view) return;
109
+ const node = e.target.closest('.mwr-node');
110
+ if (node) { step = node.dataset.step; render(); return; }
111
+ const b = e.target.closest('[data-act]');
112
+ if (!b) return;
113
+ const what = b.dataset.act;
114
+ if (what === 'propose') return openForm(b);
115
+ if (what === 'cancel') { $('mwr-form-slot').innerHTML = ''; return; }
116
+ if (what === 'approve' || what === 'decline' || what === 'vote') {
117
+ b.disabled = true;
118
+ act(what, b.dataset.id).finally(() => { b.disabled = false; });
119
+ }
120
+ });
121
+ document.addEventListener('submit', (e) => {
122
+ const form = e.target.closest('.mwr-form');
123
+ if (!form) return;
124
+ e.preventDefault();
125
+ submitForm(form);
126
+ });
127
+
128
+ load();
129
+ })();
@@ -0,0 +1,17 @@
1
+ {
2
+ "_": "The Market Watch rules page (task 1004941), built to docs/design/mocks/market-watch/rules.html, option C (the pipeline map). One read, GET /market-watch/rules. Against the kit's own stub it 404s, so the page renders its 'Market Watch is off' state, which the floors must hold too. TO SEE IT POPULATED, point the kit at the hall-preview harness: `node scripts/hall-preview/server.js --port 4641 --fixture-me`, then `render.js --page /builders/market-watch-rules --base-in http://builders.localhost:4641 --base-out http://builders.localhost:4641`. The harness answers from fixtures/market-watch__rules.json, built by the real composers (rules-view.buildView over the seed's rules and 60 items, two approved changes and four open proposals). Steps are opened with clickIfVisible because against the stub the nodes do not exist.",
3
+ "page": "/builders/market-watch-rules",
4
+ "surface": "hall-ui",
5
+ "stub": { "prefix": "/builders/" },
6
+ "modeQuery": false,
7
+ "ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox/", "/api/(gds|bongos)/market-watch/", "/api/(gds|bongos)/core-update"],
8
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
9
+ "states": {
10
+ "in": { "auth": true, "actions": [["wait", 700], ["clickIfVisible", "[data-craft-close]"], ["wait", 150]], "expect": { "visible": ["#mwr-panel"] } },
11
+ "scoring": { "auth": true, "actions": [["wait", 700], ["clickIfVisible", "[data-craft-close]"], ["wait", 150], ["clickIfVisible", ".mwr-node[data-step='scoring']"], ["wait", 200]] },
12
+ "names": { "auth": true, "actions": [["wait", 700], ["clickIfVisible", "[data-craft-close]"], ["wait", 150], ["clickIfVisible", ".mwr-node[data-step='names']"], ["wait", 200]] },
13
+ "propose": { "auth": true, "actions": [["wait", 700], ["clickIfVisible", "[data-craft-close]"], ["wait", 150], ["clickIfVisible", ".mwr-node[data-step='sources']"], ["wait", 150], ["clickIfVisible", "[data-act='propose']"], ["wait", 200]] },
14
+ "out": { "auth": false }
15
+ },
16
+ "reducedMotion": { "auth": true, "actions": [["wait", 700], ["clickIfVisible", "[data-craft-close]"], ["wait", 150]] }
17
+ }
@@ -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;
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ // modules/market-watch/matter-acts.js — what a PASSED government vote on a Market Watch
4
+ // proposal carries out (task 1004941, goal 1000137).
5
+ //
6
+ // Where the project's government hands its Rules building to a sitting (the
7
+ // decision-rules table, ADR 0363), the owner cannot approve a proposal alone: the decide
8
+ // route refuses with 409 decided_by_sitting, and the proposal is put to a vote instead
9
+ // (POST /market-watch/proposals/:id/vote, which files a Rules matter through the
10
+ // government port). When that sitting passes the matter, the government module calls
11
+ // this act through the 'governor.matterAct' contribution point, after the close commits,
12
+ // and it approves the proposal through the SAME decideProposal the owner's route calls,
13
+ // so a vote can do nothing the owner's approval could not. A returned matter acts on
14
+ // nothing; the proposal stays open for the owner to decline.
15
+ //
16
+ // A module never requires a sibling: the government reaches this only through the
17
+ // contribution, and this reaches the government only through its port.
18
+
19
+ const api = require('../../src/module-api');
20
+ const proposals = require('./proposals');
21
+ const { projectKey } = require('./seed');
22
+
23
+ const log = api.logger('market-watch');
24
+ const POINT = 'governor.matterAct';
25
+ const AREA = 'rules';
26
+ const TYPE = 'market_watch_proposal';
27
+
28
+ // Where the act finds the project and its transaction. A test (and the government walk)
29
+ // replaces these to run the act without a database or branding.
30
+ const runtime = {
31
+ project: () => projectKey(api.branding().domains.publicOrigin),
32
+ withTx: (fn) => api.withTx(fn, { pool: api.pool }),
33
+ };
34
+
35
+ async function approveByVote(action, ctx) {
36
+ const project = runtime.project();
37
+ if (!project) return { done: false, reason: 'no_project' };
38
+ const withTx = runtime.withTx;
39
+ const out = await proposals.decideProposal({
40
+ project,
41
+ id: action.proposal_id,
42
+ decision: 'approve',
43
+ decidedBy: ctx && ctx.decidedBy,
44
+ via: { decided_via: 'vote', matter_id: ctx && ctx.matterId != null ? String(ctx.matterId) : null },
45
+ withTx,
46
+ });
47
+ if (!out.ok) {
48
+ log.warn(`[market-watch] proposal ${action.proposal_id} passed by vote (matter ${ctx && ctx.matterId}) but was not applied: ${out.reason}`);
49
+ return { done: false, reason: out.reason };
50
+ }
51
+ log.info(`[market-watch] proposal ${action.proposal_id} ${out.status} by vote (matter ${ctx && ctx.matterId})`);
52
+ return { done: true };
53
+ }
54
+
55
+ function contributeMarketWatchMatterActs() {
56
+ api.contribute(POINT, { area: AREA, type: TYPE, act: (action, ctx) => approveByVote(action, ctx) });
57
+ }
58
+
59
+ module.exports = { approveByVote, contributeMarketWatchMatterActs, runtime };
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "key": "market-watch",
3
3
  "title": "Market Watch (a weekly AI watch on a project's market)",
4
- "description": "A weekly AI watch on a project's competitors, partners and topics, with a sourced log per tracked item and a weekly question to members (goal 1000137). Its data model (tables, read queries and the hub seed of the 2026-10-28 research) and its weekly run (an hourly poller that researches each tracked item on the project's own Anthropic key, under the monthly cap) are in, and so is the weekly ask card in the hall's attention inbox (task 1004940); the pages are not yet. Off by default, and it cannot be turned on behind an open join door with no payer, because every run will spend the project's AI budget.",
5
- "version": "0.3.0",
6
- "coreVersion": "^1.19.0",
4
+ "description": "A weekly AI watch on a project's competitors, partners and topics, with a sourced log per tracked item and a weekly question to members (goal 1000137). Its data model (tables, read queries and the hub seed of the 2026-10-28 research), its weekly run (an hourly poller that researches each tracked item on the project's own Anthropic key, under the monthly cap), the weekly ask card in the hall's attention inbox (task 1004940) and its rules page (the watch rules in plain words, members' proposed changes, and the owner's or the government's decision on each, task 1004941) are in. Off by default, and it cannot be turned on behind an open join door with no payer, because every run will spend the project's AI budget.",
5
+ "version": "0.4.0",
6
+ "coreVersion": "^1.22.0",
7
7
  "category": {
8
8
  "parent": "knowledge"
9
9
  },
@@ -14,7 +14,8 @@
14
14
  "contributes": {
15
15
  "migrations": true,
16
16
  "routes": [
17
- "ask"
17
+ "ask",
18
+ "rules"
18
19
  ],
19
20
  "pollers": [
20
21
  "hub-seed",