@bongos/core 1.24.10 → 1.24.11
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 +55 -35
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +10 -1
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +132 -5
- package/docs/api-reference.md +10 -3
- package/docs/architecture.md +4 -0
- package/docs/copy-inventory.md +65 -59
- package/docs/copy-registry.json +123 -69
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +33 -33
- package/modules/government/decision-rules.js +1 -0
- package/modules/hall-ui/public/project-settings.css +12 -0
- package/modules/hall-ui/public/project-settings.js +63 -1
- package/modules/hall-ui/public/task.js +34 -1
- package/modules/lifecycle/migrations/lifecycle_018_pay_terms.sql +64 -0
- package/modules/lifecycle/pay-terms.js +171 -0
- package/modules/lifecycle/routes/project-settings.js +94 -0
- package/modules/lifecycle/routes/task-write-routes.js +35 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/bongos/government-walk.js +1 -0
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/scripts/bongos/start.js +42 -2
- package/src/module-api.js +1 -1
- package/tests/government_act_alone_buildings.mjs +1 -0
- package/tests/patch_gate_ordering.mjs +3 -0
- package/tests/pay_terms.mjs +254 -0
- package/tests/pay_terms_db.mjs +86 -0
- package/tests/project_settings_page.mjs +25 -6
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// modules/lifecycle/pay-terms.js — what a task pays, in the PROJECT's words
|
|
2
|
+
// (task 1004990, goal 1000092; ADR 0385 decision 3 and its 2026-10-29 amendment).
|
|
3
|
+
//
|
|
4
|
+
// The project sets the pay, not Bongos. A task carries zero or more pay terms:
|
|
5
|
+
// a type, and an amount, unit and note exactly as the project states them. A
|
|
6
|
+
// project may also keep STANDING terms (task_id NULL) — its pay policy — which a
|
|
7
|
+
// builder sees on any task that has none of its own. Per the owner's ruling that
|
|
8
|
+
// policy lives in the database (modules/lifecycle/migrations/lifecycle_018_pay_terms.sql) and is changed
|
|
9
|
+
// on the project's settings page, with the table itself as the audit trail; it
|
|
10
|
+
// never lives in the branding pack.
|
|
11
|
+
//
|
|
12
|
+
// THREE RULES THIS FILE HOLDS:
|
|
13
|
+
// 1. Bongos suggests no amount. There is no default and nothing is filled in:
|
|
14
|
+
// a term the project gave no amount has amount null, and every reader shows
|
|
15
|
+
// it as "not stated" — never a number Bongos chose.
|
|
16
|
+
// 2. Records only. The amount is text as stated; nothing here parses, sums or
|
|
17
|
+
// converts it, so no balance or amount owed can come out of it (ADR 0387).
|
|
18
|
+
// 3. A self-managed term always carries SELF_MANAGED_LABEL, so wherever it is
|
|
19
|
+
// shown it says Bongos moves and guarantees none of that money (plan P2.4).
|
|
20
|
+
//
|
|
21
|
+
// Who may write is decided by the ROUTE (task.edit for a task's terms,
|
|
22
|
+
// project.settings.manage for the project's), on the server (ADR 0016).
|
|
23
|
+
|
|
24
|
+
const api = require('../../src/module-api');
|
|
25
|
+
|
|
26
|
+
const PAY_TYPES = ['cash', 'equity', 'module_share', 'credit_pool', 'non_monetary', 'self_managed'];
|
|
27
|
+
const PAY_TYPE_LABELS = {
|
|
28
|
+
cash: 'Cash',
|
|
29
|
+
equity: 'Equity',
|
|
30
|
+
module_share: 'A share of a module',
|
|
31
|
+
credit_pool: 'A share of a credit pool',
|
|
32
|
+
non_monetary: 'Not money',
|
|
33
|
+
self_managed: 'Paid by the project itself',
|
|
34
|
+
};
|
|
35
|
+
const SELF_MANAGED_LABEL = 'Paid by the project on its own systems. Bongos does not move or guarantee this money.';
|
|
36
|
+
const MAX_TERMS = 10;
|
|
37
|
+
const LIMITS = { amount: 64, unit: 32, note: 500 };
|
|
38
|
+
|
|
39
|
+
// PURE. Checks a list of terms as sent by a client. Returns { terms } (each
|
|
40
|
+
// { pay_type, amount, unit, note }, blanks as null) or { error }. It never fills
|
|
41
|
+
// a field in: what the project did not state stays null.
|
|
42
|
+
function normalizePayTerms(input) {
|
|
43
|
+
if (!Array.isArray(input)) return { error: 'pay_terms is a list (empty for none).' };
|
|
44
|
+
if (input.length > MAX_TERMS) return { error: `A task carries at most ${MAX_TERMS} pay terms.` };
|
|
45
|
+
const terms = [];
|
|
46
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
47
|
+
const t = input[i];
|
|
48
|
+
if (!t || typeof t !== 'object' || Array.isArray(t)) return { error: `pay term ${i + 1} is not an object.` };
|
|
49
|
+
const extra = Object.keys(t).filter((k) => !['pay_type', 'amount', 'unit', 'note'].includes(k));
|
|
50
|
+
if (extra.length) return { error: `pay term ${i + 1} has an unknown field: ${extra.join(', ')}.` };
|
|
51
|
+
if (!PAY_TYPES.includes(t.pay_type)) {
|
|
52
|
+
return { error: `pay term ${i + 1}: pay_type must be one of ${PAY_TYPES.join(', ')}.` };
|
|
53
|
+
}
|
|
54
|
+
const out = { pay_type: t.pay_type };
|
|
55
|
+
for (const field of ['amount', 'unit', 'note']) {
|
|
56
|
+
const v = t[field];
|
|
57
|
+
if (v == null) { out[field] = null; continue; }
|
|
58
|
+
if (typeof v !== 'string') return { error: `pay term ${i + 1}: ${field} is text, as the project states it.` };
|
|
59
|
+
const s = v.trim();
|
|
60
|
+
if (s.length > LIMITS[field]) return { error: `pay term ${i + 1}: ${field} is at most ${LIMITS[field]} characters.` };
|
|
61
|
+
out[field] = s || null;
|
|
62
|
+
}
|
|
63
|
+
terms.push(out);
|
|
64
|
+
}
|
|
65
|
+
return { terms };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// PURE. One stored term as every reader shows it. The self-managed label rides on
|
|
69
|
+
// the term itself, so no surface can show the term without it.
|
|
70
|
+
function displayTerm(row) {
|
|
71
|
+
return {
|
|
72
|
+
pay_type: row.pay_type,
|
|
73
|
+
type_label: PAY_TYPE_LABELS[row.pay_type] || row.pay_type,
|
|
74
|
+
amount: row.amount ?? null,
|
|
75
|
+
unit: row.unit ?? null,
|
|
76
|
+
note: row.note ?? null,
|
|
77
|
+
label: row.pay_type === 'self_managed' ? SELF_MANAGED_LABEL : null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Append one set: the whole list of terms for a task (taskId) or for the project
|
|
82
|
+
// (taskId null). One transaction, so a set is never half-written. Returns the set.
|
|
83
|
+
async function setPayTerms({ taskId = null, terms, builderId }, { db } = {}) {
|
|
84
|
+
const run = async (client) => {
|
|
85
|
+
const { rows: [set] } = await client.query(
|
|
86
|
+
'INSERT INTO lifecycle_pay_term_sets (task_id, set_by) VALUES ($1, $2) RETURNING id, task_id, set_by, set_at',
|
|
87
|
+
[taskId, builderId]);
|
|
88
|
+
// Every term in ONE statement, positions from unnest's ordinality, so a set of
|
|
89
|
+
// ten is one round trip, not ten.
|
|
90
|
+
if (terms.length) {
|
|
91
|
+
await client.query(
|
|
92
|
+
`INSERT INTO lifecycle_pay_terms (set_id, position, pay_type, amount, unit, note)
|
|
93
|
+
SELECT $1, (t.n - 1)::smallint, t.pay_type, t.amount, t.unit, t.note
|
|
94
|
+
FROM unnest($2::text[], $3::text[], $4::text[], $5::text[])
|
|
95
|
+
WITH ORDINALITY AS t(pay_type, amount, unit, note, n)`,
|
|
96
|
+
[set.id, terms.map((t) => t.pay_type), terms.map((t) => t.amount), terms.map((t) => t.unit), terms.map((t) => t.note)]);
|
|
97
|
+
}
|
|
98
|
+
return { set_id: set.id, task_id: set.task_id, set_by: set.set_by, set_at: set.set_at, terms: terms.map(displayTerm) };
|
|
99
|
+
};
|
|
100
|
+
if (db) return run(db);
|
|
101
|
+
return api.withTx(run);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The current sets for these tasks, plus the project's standing set, in two reads
|
|
105
|
+
// (lifecycle_pay_term_sets_current). Returns Map<taskIdString, view>, where a view is
|
|
106
|
+
// { source: 'task' | 'project' | 'none', terms, set_by, set_at }. A task whose
|
|
107
|
+
// newest set is EMPTY says so ('task', no terms): the project chose "no pay terms"
|
|
108
|
+
// for it, which the standing terms must not paper over.
|
|
109
|
+
async function readPayTerms(taskIds, { db } = {}) {
|
|
110
|
+
const exec = db || api.pool;
|
|
111
|
+
const ids = [...new Set((taskIds || []).map((x) => String(x)))];
|
|
112
|
+
const { rows: sets } = await exec.query(
|
|
113
|
+
`SELECT DISTINCT ON (task_id) id, task_id, set_by, set_at
|
|
114
|
+
FROM lifecycle_pay_term_sets
|
|
115
|
+
WHERE task_id = ANY($1::bigint[]) OR task_id IS NULL
|
|
116
|
+
ORDER BY task_id, set_at DESC, id DESC`, [ids]);
|
|
117
|
+
const setIds = sets.map((s) => s.id);
|
|
118
|
+
const termsBySet = new Map();
|
|
119
|
+
if (setIds.length) {
|
|
120
|
+
const { rows } = await exec.query(
|
|
121
|
+
`SELECT set_id, pay_type, amount, unit, note FROM lifecycle_pay_terms
|
|
122
|
+
WHERE set_id = ANY($1::bigint[]) ORDER BY set_id, position`, [setIds]);
|
|
123
|
+
for (const r of rows) {
|
|
124
|
+
const k = String(r.set_id);
|
|
125
|
+
if (!termsBySet.has(k)) termsBySet.set(k, []);
|
|
126
|
+
termsBySet.get(k).push(displayTerm(r));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const view = (s, source) => ({ source, terms: termsBySet.get(String(s.id)) || [], set_by: s.set_by, set_at: s.set_at });
|
|
130
|
+
const project = sets.find((s) => s.task_id == null);
|
|
131
|
+
const out = new Map();
|
|
132
|
+
for (const id of ids) {
|
|
133
|
+
const own = sets.find((s) => s.task_id != null && String(s.task_id) === id);
|
|
134
|
+
if (own) out.set(id, view(own, 'task'));
|
|
135
|
+
else if (project && (termsBySet.get(String(project.id)) || []).length) out.set(id, view(project, 'project'));
|
|
136
|
+
else out.set(id, { source: 'none', terms: [], set_by: null, set_at: null });
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The project's standing terms and their history (newest first, at most `limit`
|
|
142
|
+
// sets), for the settings page: who changed what, and when.
|
|
143
|
+
async function readProjectPayTerms({ db, limit = 10 } = {}) {
|
|
144
|
+
const exec = db || api.pool;
|
|
145
|
+
const { rows: sets } = await exec.query(
|
|
146
|
+
`SELECT s.id, s.set_by, s.set_at, b.github_login AS set_by_login
|
|
147
|
+
FROM lifecycle_pay_term_sets s LEFT JOIN builders b ON b.id = s.set_by
|
|
148
|
+
WHERE s.task_id IS NULL
|
|
149
|
+
ORDER BY s.set_at DESC, s.id DESC LIMIT $1`, [limit]);
|
|
150
|
+
const termsBySet = new Map();
|
|
151
|
+
if (sets.length) {
|
|
152
|
+
const { rows } = await exec.query(
|
|
153
|
+
`SELECT set_id, pay_type, amount, unit, note FROM lifecycle_pay_terms
|
|
154
|
+
WHERE set_id = ANY($1::bigint[]) ORDER BY set_id, position`, [sets.map((s) => s.id)]);
|
|
155
|
+
for (const r of rows) {
|
|
156
|
+
const k = String(r.set_id);
|
|
157
|
+
if (!termsBySet.has(k)) termsBySet.set(k, []);
|
|
158
|
+
termsBySet.get(k).push(displayTerm(r));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const history = sets.map((s) => ({
|
|
162
|
+
set_by: s.set_by, set_by_login: s.set_by_login || null, set_at: s.set_at,
|
|
163
|
+
terms: termsBySet.get(String(s.id)) || [],
|
|
164
|
+
}));
|
|
165
|
+
return { terms: history.length ? history[0].terms : [], history };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = {
|
|
169
|
+
PAY_TYPES, PAY_TYPE_LABELS, SELF_MANAGED_LABEL, MAX_TERMS,
|
|
170
|
+
normalizePayTerms, displayTerm, setPayTerms, readPayTerms, readProjectPayTerms,
|
|
171
|
+
};
|
|
@@ -34,9 +34,49 @@ const express = require('express');
|
|
|
34
34
|
const api = require('../../../src/module-api');
|
|
35
35
|
const goalCategory = require('../goal-category');
|
|
36
36
|
const autopickup = require('../autopickup');
|
|
37
|
+
const payTermsLib = require('../pay-terms'); // task 1004990
|
|
37
38
|
|
|
38
39
|
const log = api.logger('lifecycle');
|
|
39
40
|
|
|
41
|
+
// --- pay terms: the project's standing pay policy (task 1004990) --------------
|
|
42
|
+
//
|
|
43
|
+
// ADR 0385 decision 3: the project, not Bongos, sets the pay. The owner ruled
|
|
44
|
+
// (2026-10-29) that this policy lives in a database table, changed here, with an
|
|
45
|
+
// audit trail — never in the branding pack. The row carries the current terms,
|
|
46
|
+
// the recent history (who, when, what) and the six types, and suggests no amount.
|
|
47
|
+
function payTermsRow(policy) {
|
|
48
|
+
if (!policy) {
|
|
49
|
+
return {
|
|
50
|
+
key: 'pay_terms',
|
|
51
|
+
title: 'Pay terms',
|
|
52
|
+
editable: false,
|
|
53
|
+
state: 'unavailable',
|
|
54
|
+
sentence: 'The project’s pay terms could not be read just now.',
|
|
55
|
+
source: 'lifecycle_pay_term_sets / lifecycle_pay_terms tables',
|
|
56
|
+
reads: { file: 'lifecycle_pay_terms (migration lifecycle_018)', adr: 'ADR 0385' },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const terms = policy.terms || [];
|
|
60
|
+
return {
|
|
61
|
+
key: 'pay_terms',
|
|
62
|
+
title: 'Pay terms',
|
|
63
|
+
editable: true,
|
|
64
|
+
state: 'live',
|
|
65
|
+
value: {
|
|
66
|
+
terms,
|
|
67
|
+
history: (policy && policy.history) || [],
|
|
68
|
+
types: payTermsLib.PAY_TYPES.map((t) => ({ type: t, label: payTermsLib.PAY_TYPE_LABELS[t] })),
|
|
69
|
+
max: payTermsLib.MAX_TERMS,
|
|
70
|
+
self_managed_label: payTermsLib.SELF_MANAGED_LABEL,
|
|
71
|
+
},
|
|
72
|
+
sentence: terms.length
|
|
73
|
+
? 'What this project pays for its work, in its own words. A builder sees it before claiming any task that has no pay terms of its own. Bongos records it and moves no money.'
|
|
74
|
+
: 'This project has not stated standing pay terms. A task can still carry its own. Bongos suggests no amount and moves no money.',
|
|
75
|
+
source: 'lifecycle_pay_term_sets / lifecycle_pay_terms tables',
|
|
76
|
+
reads: { file: 'lifecycle_pay_terms (migration lifecycle_018)', adr: 'ADR 0385' },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
40
80
|
// --- ship mode: who starts a ship (task 1004628, ADR 0369) -------------------
|
|
41
81
|
//
|
|
42
82
|
// One row shape, two readers: the settings page (inside GET /project-settings) and
|
|
@@ -292,6 +332,12 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
292
332
|
const categories = await goalCategory.listCategories({ includeInactive: true });
|
|
293
333
|
const shipMode = await api.projectSettings.resolveShipMode();
|
|
294
334
|
const tweak = await api.projectSettings.resolveTweakAutopickup();
|
|
335
|
+
// One unreadable table must not take the whole page down: the row says it
|
|
336
|
+
// could not be read, and the other rows still draw.
|
|
337
|
+
const payPolicy = await payTermsLib.readProjectPayTerms().catch((err) => {
|
|
338
|
+
log.error(`GET /project-settings: pay terms read failed: ${err && err.message}`);
|
|
339
|
+
return null;
|
|
340
|
+
});
|
|
295
341
|
|
|
296
342
|
// A settings read is a configuration comparison — a cached one is wrong the
|
|
297
343
|
// moment somebody turns a knob (the rot.js precedent).
|
|
@@ -339,6 +385,7 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
339
385
|
source: 'work_categories table',
|
|
340
386
|
reads: { file: 'work_categories (migration core_226)', adr: 'ADR 0192' },
|
|
341
387
|
},
|
|
388
|
+
payTermsRow(payPolicy),
|
|
342
389
|
creditRow(),
|
|
343
390
|
equityRow(),
|
|
344
391
|
upmarketRow(),
|
|
@@ -464,6 +511,53 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
464
511
|
}
|
|
465
512
|
});
|
|
466
513
|
|
|
514
|
+
// GET /pay-terms?task_ids=1,2,3 — what each task pays, in the project's words
|
|
515
|
+
// (task 1004990, ADR 0385 D3), for a list of tasks in one read: the hall task page
|
|
516
|
+
// and `bongos start` show it so a builder sees the pay BEFORE claiming. Each entry
|
|
517
|
+
// is { source: 'task' | 'project' | 'none', terms, set_by, set_at }; a task with
|
|
518
|
+
// none of its own shows the project's standing terms (set below), and a
|
|
519
|
+
// self-managed term carries its "Bongos does not move or guarantee this money"
|
|
520
|
+
// label. Records only — no amount here is a Bongos figure. It lives beside the
|
|
521
|
+
// standing-terms write because both serve the one pay-terms table pair.
|
|
522
|
+
// rank: any-builder — the same task metadata GET /tasks/:id serves; nothing changed.
|
|
523
|
+
router.get('/pay-terms', api.requireBuilder, async (req, res) => {
|
|
524
|
+
const raw = String((req.query && req.query.task_ids) || '');
|
|
525
|
+
const ids = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
526
|
+
if (!ids.length || ids.length > 50 || !ids.every((s) => /^\d{1,18}$/.test(s))) {
|
|
527
|
+
return res.fail('bad_task_ids', { status: 400, message: 'task_ids is a comma list of 1 to 50 task ids.' });
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
const map = await payTermsLib.readPayTerms(ids);
|
|
531
|
+
res.json({ pay_terms: Object.fromEntries(map) });
|
|
532
|
+
} catch (err) {
|
|
533
|
+
log.error(`GET /pay-terms: ${err && err.message}`);
|
|
534
|
+
res.fail('pay_terms_read_failed', { status: 500, message: 'internal error' });
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// PATCH /project-settings/pay-terms — body { terms: [{ pay_type, amount?, unit?, note? }] }
|
|
539
|
+
// (task 1004990; ADR 0385 decision 3 and the owner's 2026-10-29 ruling). The
|
|
540
|
+
// project's STANDING pay terms: what a builder is shown on any task that carries
|
|
541
|
+
// none of its own. The whole list replaces the current set; [] means none. Every
|
|
542
|
+
// change is a new row naming who set it and when, and the old sets stay, so the
|
|
543
|
+
// table is the audit trail the owner asked for. Bongos fills nothing in.
|
|
544
|
+
//
|
|
545
|
+
// rank: project.settings.manage (metic+, government_010), the same atom as the
|
|
546
|
+
// other writes on this page.
|
|
547
|
+
router.patch('/project-settings/pay-terms', api.requireBuilder, api.requirePermission('project.settings.manage'), api.requireActAlone('rules'), async (req, res) => {
|
|
548
|
+
if (api.validateOrRespond(req, res, { terms: { required: true } })) return; // a list; checked by pay-terms.js
|
|
549
|
+
const parsed = payTermsLib.normalizePayTerms(req.body.terms);
|
|
550
|
+
if (parsed.error) return res.fail('bad_pay_terms', { status: 400, message: parsed.error });
|
|
551
|
+
try {
|
|
552
|
+
await payTermsLib.setPayTerms({ taskId: null, terms: parsed.terms, builderId: req.builder.id });
|
|
553
|
+
const policy = await payTermsLib.readProjectPayTerms();
|
|
554
|
+
res.json({ ok: true, row: payTermsRow(policy) });
|
|
555
|
+
} catch (err) {
|
|
556
|
+
log.error(`PATCH /project-settings/pay-terms: ${err && err.message}`);
|
|
557
|
+
res.fail('project_settings_write_failed', { status: 500, message: 'internal error' });
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
|
|
467
561
|
// --- tweak auto-pickup (task 1004677, ADR 0371 D3/D5) ----------------------
|
|
468
562
|
//
|
|
469
563
|
// GET /project-settings/tweak-autopickup — the switch, the monthly cap, and how
|
|
@@ -28,6 +28,7 @@ const { isModuleKey, moduleKeys } = api.moduleScopeMap;
|
|
|
28
28
|
const { validateOrRespond, LIMITS, parseId } = api;
|
|
29
29
|
const { badKind, badNeeds, detectsMigration } = require('./task-route-helpers.js');
|
|
30
30
|
const { describeCheckViolation } = require('../pg-constraint-fail.js');
|
|
31
|
+
const payTermsLib = require('../pay-terms.js'); // task 1004990
|
|
31
32
|
const { goalNotOpenRefusal } = require('./goal-route-authz.js');
|
|
32
33
|
const { resolveUpkeepHome, isUpkeepFiling, UPKEEP_HINT } = require('../upkeep-home.js'); // task 1004597
|
|
33
34
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
@@ -39,6 +40,11 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
39
40
|
// shape tasks. Was Archon-only (task #106 / ADR 0016; three-rank model #360 /
|
|
40
41
|
// ADR 0018). A Xenos/Thetes still cannot author — they file ideas. Setting rank
|
|
41
42
|
// and closing versions stay Archon. The every-route audit is task #231 (V3.R06).
|
|
43
|
+
// NO pay_terms HERE, on purpose (task 1004990): the task's rule is that only
|
|
44
|
+
// someone who may EDIT a task sets its pay terms, and this route is gated on
|
|
45
|
+
// task.create, not task.edit — so terms are set with PATCH /tasks/:id right after
|
|
46
|
+
// create, under that route's task.edit gate. The strict validator refuses the
|
|
47
|
+
// field here rather than letting a create quietly widen who may set pay.
|
|
42
48
|
// rank: metic+archon — create work (plan/scope mutation); ADR 0090 (was ADR 0016/0018 archon).
|
|
43
49
|
router.post('/tasks', auth.requireBuilder, auth.requirePermission('task.create'), async (req, res) => {
|
|
44
50
|
if (validateOrRespond(req, res, {
|
|
@@ -387,6 +393,10 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
387
393
|
// the hint had been unreachable dead code behind unknown_field since the
|
|
388
394
|
// R11 strict-validation sweep landed after it.
|
|
389
395
|
status: {},
|
|
396
|
+
// task 1004990 (ADR 0385 D3): the task's pay terms, in the project's own
|
|
397
|
+
// words — a list, [] for none. Checked inline by pay-terms.js; the whole list
|
|
398
|
+
// replaces the current set and the old set stays as history.
|
|
399
|
+
pay_terms: {},
|
|
390
400
|
})) return;
|
|
391
401
|
const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
|
|
392
402
|
if (id === null) return;
|
|
@@ -412,6 +422,7 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
412
422
|
const hasTitle = Object.prototype.hasOwnProperty.call(body, 'title');
|
|
413
423
|
const hasPriority = Object.prototype.hasOwnProperty.call(body, 'priority');
|
|
414
424
|
const hasValueSummary = Object.prototype.hasOwnProperty.call(body, 'value_summary');
|
|
425
|
+
const hasPayTerms = Object.prototype.hasOwnProperty.call(body, 'pay_terms');
|
|
415
426
|
// #514: status is a lifecycle field, not a PATCH-able column — it moves only
|
|
416
427
|
// through the dedicated transition routes. The #506 session tried
|
|
417
428
|
// `PATCH status=ready`, got the generic no_supported_fields error, and had
|
|
@@ -423,8 +434,8 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
423
434
|
if (Object.prototype.hasOwnProperty.call(body, 'status')) {
|
|
424
435
|
return res.fail('status_not_patchable', { status: 400, message: STATUS_HINT });
|
|
425
436
|
}
|
|
426
|
-
if (!hasParent && !hasKind && !hasDiscipline && !hasNewcomerOrXenos && !hasParallelSafe && !hasSecuritySensitive && !hasAutomationTag && !hasNeeds && !hasNeedsMigration && !hasRequiresRank && !hasModuleKey && !hasGoalId && !hasDescription && !hasTitle && !hasPriority && !hasValueSummary) {
|
|
427
|
-
return res.fail('no_supported_fields', { status: 400, message: 'PATCH /tasks/:id supports parent_task_id, kind, discipline, title, description, value_summary, priority, parallel_safe, security_sensitive, automation_tag, needs, needs_migration, requires_rank, module_key, goal_id, and newcomer_friendly (alias: xenos_claimable). ' + STATUS_HINT });
|
|
437
|
+
if (!hasParent && !hasKind && !hasDiscipline && !hasNewcomerOrXenos && !hasParallelSafe && !hasSecuritySensitive && !hasAutomationTag && !hasNeeds && !hasNeedsMigration && !hasRequiresRank && !hasModuleKey && !hasGoalId && !hasDescription && !hasTitle && !hasPriority && !hasValueSummary && !hasPayTerms) {
|
|
438
|
+
return res.fail('no_supported_fields', { status: 400, message: 'PATCH /tasks/:id supports parent_task_id, kind, discipline, title, description, value_summary, priority, pay_terms, parallel_safe, security_sensitive, automation_tag, needs, needs_migration, requires_rank, module_key, goal_id, and newcomer_friendly (alias: xenos_claimable). ' + STATUS_HINT });
|
|
428
439
|
}
|
|
429
440
|
// ---- task 1002609 (G-22): settle EVERY gate and validation before the ----
|
|
430
441
|
// ---- FIRST write. The previous shape validated-and-wrote per field in ----
|
|
@@ -555,6 +566,16 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
555
566
|
return res.fail(`bad_${field}`, { status: 400, message: `${field} must be a boolean` });
|
|
556
567
|
}
|
|
557
568
|
}
|
|
569
|
+
// task 1004990: pay terms are checked here with the other gates, so a bad list
|
|
570
|
+
// refuses the whole request before any field is written. Who may set them is
|
|
571
|
+
// this route's own task.edit gate (ADR 0016) — the same person who may edit
|
|
572
|
+
// the task. Nothing is filled in: a term without an amount stays without one.
|
|
573
|
+
let payTerms = null;
|
|
574
|
+
if (hasPayTerms) {
|
|
575
|
+
payTerms = payTermsLib.normalizePayTerms(body.pay_terms);
|
|
576
|
+
if (payTerms.error) return res.fail('bad_pay_terms', { status: 400, message: payTerms.error });
|
|
577
|
+
if (!(await db.getTask(id))) return res.fail('task_not_found', 404);
|
|
578
|
+
}
|
|
558
579
|
// ---- all gates passed: apply the writes. Order preserved from the old ----
|
|
559
580
|
// shape — requires_rank still runs AFTER security_sensitive so a
|
|
560
581
|
// same-request flag flip is already reflected in the derived floor.
|
|
@@ -695,10 +716,20 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
695
716
|
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
696
717
|
}
|
|
697
718
|
}
|
|
698
|
-
|
|
719
|
+
let payTermsSet;
|
|
720
|
+
if (hasPayTerms) {
|
|
721
|
+
try {
|
|
722
|
+
payTermsSet = await payTermsLib.setPayTerms({ taskId: id, terms: payTerms.terms, builderId: req.builder.id });
|
|
723
|
+
if (!updated) updated = await db.getTask(id);
|
|
724
|
+
} catch (err) {
|
|
725
|
+
log.error('[gds] PATCH /tasks/:id pay_terms', err);
|
|
726
|
+
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (!hasParent) return res.json(payTermsSet ? { task: updated, pay_terms: payTermsSet } : { task: updated });
|
|
699
730
|
try {
|
|
700
731
|
const task = await hierarchy.attachParent(id, body.parent_task_id);
|
|
701
|
-
res.json({ task });
|
|
732
|
+
res.json(payTermsSet ? { task, pay_terms: payTermsSet } : { task });
|
|
702
733
|
} catch (err) {
|
|
703
734
|
if (err && err.code) {
|
|
704
735
|
const status =
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.24.
|
|
3
|
+
"version": "1.24.11",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.24.
|
|
9
|
+
"version": "1.24.11",
|
|
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.
|
|
3
|
+
"version": "1.24.11",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -10895,5 +10895,11 @@
|
|
|
10895
10895
|
"id": "1004938",
|
|
10896
10896
|
"text": "The project owner now has a Market Watch settings page where they can switch it on or off, set the monthly AI spending cap and how often it runs, see this month's spend and recent runs, and paste the project's Anthropic key, w"
|
|
10897
10897
|
}
|
|
10898
|
+
],
|
|
10899
|
+
"1.24.11": [
|
|
10900
|
+
{
|
|
10901
|
+
"id": "1004990",
|
|
10902
|
+
"text": "A project can now say in its own words what a task pays, and set its standing pay terms on the settings page with a record of who changed them; builders see the pay on the task page and in their task list before claiming, and"
|
|
10903
|
+
}
|
|
10898
10904
|
]
|
|
10899
10905
|
}
|
|
@@ -82,6 +82,7 @@ const REQUESTS = Object.freeze({
|
|
|
82
82
|
'PATCH /project-settings/rot-days': ['PATCH', '/project-settings/rot-days', { days: 'soon' }],
|
|
83
83
|
'PATCH /project-settings/ship-mode': ['PATCH', '/project-settings/ship-mode', { mode: 'sometimes' }],
|
|
84
84
|
'PATCH /project-settings/tweak-autopickup': ['PATCH', '/project-settings/tweak-autopickup', { mode: 'sometimes' }],
|
|
85
|
+
'PATCH /project-settings/pay-terms': ['PATCH', '/project-settings/pay-terms', { terms: 'not a list' }],
|
|
85
86
|
'PATCH /project-settings/spend-cap': ['PATCH', '/project-settings/spend-cap', { usd: 'lots' }],
|
|
86
87
|
'PATCH /work-categories/:id': ['PATCH', `/work-categories/${BAD}`, {}],
|
|
87
88
|
'POST /market-watch/proposals/:id/decide': ['POST', `/market-watch/proposals/${BAD}/decide`, { decision: 'approve' }],
|
|
@@ -262,6 +262,10 @@ const INTEGRATION = new Set([
|
|
|
262
262
|
// task 1004914 (ADR 0385 D8): the payout gate tables from the real migration —
|
|
263
263
|
// append-only, no silent hold, who may appeal, decide and co-sign, the gate end to end.
|
|
264
264
|
'payout_gates_db',
|
|
265
|
+
// task 1004990 (ADR 0385 D3): pay terms from the real migration — the history
|
|
266
|
+
// cannot be edited, deleted or truncated; unknown types and blank amounts are
|
|
267
|
+
// refused; the reads' SQL resolves a task's own set over the project's.
|
|
268
|
+
'pay_terms_db',
|
|
265
269
|
// task 1004988 (ADR 0385 D2): credit_log from the real migration — Postgres
|
|
266
270
|
// refuses a spend, redemption or transfer row; only a reversal of an existing
|
|
267
271
|
// row may be negative, and core_194's clawback still books.
|
package/scripts/bongos/start.js
CHANGED
|
@@ -229,6 +229,40 @@ function whatItIs(t) {
|
|
|
229
229
|
return clip(t.title, 64);
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
// ---------- pay terms (task 1004990, ADR 0385 decision 3) ----------
|
|
233
|
+
// What each task pays, in the PROJECT's words, so a builder sees it before
|
|
234
|
+
// claiming. One read for the whole list (GET /pay-terms), best-effort: an older
|
|
235
|
+
// server without the route leaves the card exactly as it was. Amounts are the
|
|
236
|
+
// project's text, never a figure this CLI computes; a missing one reads
|
|
237
|
+
// "amount not stated"; a self-managed term carries its "Bongos does not move or
|
|
238
|
+
// guarantee this money" label.
|
|
239
|
+
async function withPayTerms(api, rows) {
|
|
240
|
+
if (!rows.length || !api.payTerms || !api.payTerms.getPayTerms) return rows;
|
|
241
|
+
let res;
|
|
242
|
+
try {
|
|
243
|
+
res = await api.payTerms.getPayTerms({ query: { task_ids: rows.slice(0, 50).map((t) => t.id).join(',') } });
|
|
244
|
+
} catch (_) { return rows; }
|
|
245
|
+
const map = res && res.ok && res.data && res.data.pay_terms;
|
|
246
|
+
if (!map) return rows;
|
|
247
|
+
return rows.map((t) => (map[String(t.id)] ? { ...t, pay_terms: map[String(t.id)] } : t));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// PURE. One line per task that states pay, or null for none.
|
|
251
|
+
function payTermsLine(view) {
|
|
252
|
+
if (!view || !Array.isArray(view.terms) || !view.terms.length) return null;
|
|
253
|
+
const parts = view.terms.map((term) => {
|
|
254
|
+
const amount = term.amount ? `${term.amount}${term.unit ? ' ' + term.unit : ''}` : 'amount not stated';
|
|
255
|
+
return `${term.type_label || term.pay_type}: ${amount}${term.note ? ` (${term.note})` : ''}${term.label ? ` — ${term.label}` : ''}`;
|
|
256
|
+
});
|
|
257
|
+
return (view.source === 'project' ? 'project terms · ' : '') + parts.join('; ');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function payTermsBlock(rows) {
|
|
261
|
+
const lines = rows.map((t) => [t, payTermsLine(t.pay_terms)]).filter(([, l]) => l)
|
|
262
|
+
.map(([t, l]) => ` ${surface.taskRef(t.id, HALL_TASK)} ${l}`);
|
|
263
|
+
return lines.length ? ['', surface.strong('Pay, as each project states it'), ...lines].join('\n') : '';
|
|
264
|
+
}
|
|
265
|
+
|
|
232
266
|
// The claimable table — # / Version / Type / Reward / Est. / What it is.
|
|
233
267
|
// Ids are emitted as hall links (renders as a clean #N) — a bare "#NNN" in a
|
|
234
268
|
// relayed chat message autolinks to GitHub and 404s (CLAUDE.md ref rule).
|
|
@@ -257,6 +291,8 @@ function claimableTable(rows, rewardFromServer, goals) {
|
|
|
257
291
|
if (out.length) out.push('');
|
|
258
292
|
out.push(surface.strong(g.title) + (g.joined ? ' · your goal' : ''), surface.table(headers, g.rows.map(rowCells)));
|
|
259
293
|
}
|
|
294
|
+
const pay = payTermsBlock(rows);
|
|
295
|
+
if (pay) out.push(pay);
|
|
260
296
|
const cpNote = costPlusOnlyNote(rewardFromServer);
|
|
261
297
|
if (cpNote) out.push('', surface.em(cpNote));
|
|
262
298
|
return out.join('\n');
|
|
@@ -465,7 +501,9 @@ function renderWidget({ builder, claims, claimable, total, streak, recommendatio
|
|
|
465
501
|
+ TD(hesc((t.credits_reward ?? 0) + 'c'))
|
|
466
502
|
+ TD(hesc(estimateShort(t)), 'bs-sub')
|
|
467
503
|
+ TD(hesc(t.title || ''), 'bs-ellip')
|
|
468
|
-
+ `</tr
|
|
504
|
+
+ `</tr>`
|
|
505
|
+
// task 1004990: the project's pay terms under the task, before any claim.
|
|
506
|
+
+ (payTermsLine(t.pay_terms) ? `<tr class="bs-pay"><td colspan="6" class="bs-sub">Pay: ${hesc(payTermsLine(t.pay_terms))}</td></tr>` : '');
|
|
469
507
|
};
|
|
470
508
|
// task 1002852: the same rows, grouped under each task's goal ("Other work" last).
|
|
471
509
|
const rows = startGoals.groupByGoal(claimable, goals).map((g) =>
|
|
@@ -764,7 +802,7 @@ async function main() {
|
|
|
764
802
|
// task 1002852: goal-first. The open goals (with the server's viewer_is_member)
|
|
765
803
|
// group the table and put the builder's joined goals' rows ahead of the cap.
|
|
766
804
|
const goals = goalsRes && goalsRes.ok && Array.isArray(goalsRes.data?.goals) ? goalsRes.data.goals : [];
|
|
767
|
-
const claimable = startGoals.joinedFirst(filtered, goals).slice(0, limit);
|
|
805
|
+
const claimable = await withPayTerms(api, startGoals.joinedFirst(filtered, goals).slice(0, limit));
|
|
768
806
|
|
|
769
807
|
// "You might enjoy" — up to 3 claimable tasks outside the builder's lanes.
|
|
770
808
|
// Computed from the FULL set so it can reach across disciplines. Suppressed
|
|
@@ -1087,4 +1125,6 @@ module.exports = {
|
|
|
1087
1125
|
isConnectionError,
|
|
1088
1126
|
instanceNotRunningCard,
|
|
1089
1127
|
isLocalOrigin,
|
|
1128
|
+
payTermsLine,
|
|
1129
|
+
withPayTerms,
|
|
1090
1130
|
};
|
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.
|
|
84
|
+
const CORE_VERSION = '1.24.11'; // 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');
|
|
@@ -243,6 +243,7 @@ const REQUESTS = {
|
|
|
243
243
|
'PATCH /project-settings/rot-days': ['PATCH', '/project-settings/rot-days', { days: 'soon' }],
|
|
244
244
|
'PATCH /project-settings/ship-mode': ['PATCH', '/project-settings/ship-mode', { mode: 'sometimes' }],
|
|
245
245
|
'PATCH /project-settings/tweak-autopickup': ['PATCH', '/project-settings/tweak-autopickup', { mode: 'sometimes' }],
|
|
246
|
+
'PATCH /project-settings/pay-terms': ['PATCH', '/project-settings/pay-terms', { terms: 'not a list' }],
|
|
246
247
|
'PATCH /work-categories/:id': ['PATCH', `/work-categories/${BAD}`, {}],
|
|
247
248
|
// task 1004941: the owner deciding a Market Watch proposal
|
|
248
249
|
'POST /market-watch/proposals/:id/decide': ['POST', `/market-watch/proposals/${BAD}/decide`, { decision: 'approve' }],
|
|
@@ -55,6 +55,9 @@ const REFUSALS = [
|
|
|
55
55
|
"'bad_value_summary'",
|
|
56
56
|
'badKind(res)',
|
|
57
57
|
"'bad_discipline'",
|
|
58
|
+
// task 1004990: pay_terms joined the PATCH surface; a bad list refuses the
|
|
59
|
+
// whole request before any field is written.
|
|
60
|
+
"'bad_pay_terms'",
|
|
58
61
|
];
|
|
59
62
|
|
|
60
63
|
function firstWriteIndex(slice) {
|