@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.
- package/.bongos-core.json +106 -46
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +13 -1
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0371-auto-pickup-applies-a-submitted-tweak-on-the-runner.md +1 -0
- package/docs/api/openapi.json +264 -4
- package/docs/api-reference.md +7 -3
- package/docs/copy-inventory.md +41 -13
- package/docs/copy-registry.json +319 -35
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +40 -4
- package/docs/page-readings.json +69 -9
- package/modules/government/decision-board.js +5 -0
- package/modules/government/decision-rules.js +3 -0
- package/modules/government/routes/government.js +2 -0
- package/modules/hall-ui/public/market-watch-rules-lib.js +207 -0
- package/modules/hall-ui/public/market-watch-rules.css +68 -0
- package/modules/hall-ui/public/market-watch-rules.html +54 -0
- package/modules/hall-ui/public/market-watch-rules.js +129 -0
- package/modules/hall-ui/public/market-watch-rules.states.json +17 -0
- package/modules/hall-ui/public/project-settings.html +4 -4
- package/modules/hall-ui/public/project-settings.js +51 -1
- package/modules/lifecycle/autopickup.js +26 -9
- package/modules/lifecycle/db-claims.js +1 -1
- package/modules/lifecycle/routes/project-settings.js +71 -10
- package/modules/market-watch/matter-acts.js +59 -0
- package/modules/market-watch/module.json +5 -4
- package/modules/market-watch/proposals.js +214 -0
- package/modules/market-watch/research.js +49 -8
- package/modules/market-watch/routes/rules.js +202 -0
- package/modules/market-watch/rules-logic.js +264 -0
- package/modules/market-watch/rules-view.js +165 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/bongos/government-walk.js +18 -2
- package/scripts/bongos/run-unit-tests.js +3 -0
- package/src/bongos/module-scope-map.js +2 -0
- package/src/bongos/project-settings.js +59 -15
- package/src/bongos/serve-internal.js +2 -1
- package/src/module-api.js +1 -1
- package/tests/autopickup_cap.mjs +203 -11
- package/tests/government_act_alone_buildings.mjs +3 -0
- package/tests/government_decision_rules.mjs +4 -1
- package/tests/governor_matter_acts.mjs +5 -1
- package/tests/hall_page_gate_map.mjs +3 -0
- package/tests/market_watch_module.mjs +1 -0
- package/tests/market_watch_rules.mjs +496 -0
- package/tests/market_watch_rules_db.mjs +133 -0
- 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
|
-
|
|
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,
|
|
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-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
62
|
-
// precedent: a settings read is not part of the claim's
|
|
63
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
5
|
-
"version": "0.
|
|
6
|
-
"coreVersion": "^1.
|
|
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",
|