@bongos/core 1.24.10 → 1.24.12
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 +171 -86
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +12 -0
- package/clients/bongos-client/index.cjs +12 -0
- package/clients/bongos-client/index.d.ts +18 -1
- package/clients/bongos-client/index.mjs +12 -0
- package/docs/api/openapi.json +304 -5
- package/docs/api-reference.md +14 -4
- package/docs/architecture.md +4 -0
- package/docs/copy-inventory.md +154 -76
- package/docs/copy-registry.json +923 -125
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +68 -4
- package/docs/page-readings.json +873 -819
- package/modules/government/decision-rules.js +1 -0
- package/modules/hall-ui/public/ai-studio.html +1 -1
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/craft-mode.js +2 -2
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/market-watch-item.html +83 -0
- package/modules/hall-ui/public/market-watch-item.js +150 -0
- package/modules/hall-ui/public/market-watch-item.states.json +143 -0
- package/modules/hall-ui/public/market-watch-lib.js +243 -0
- package/modules/hall-ui/public/market-watch-rules.html +1 -1
- package/modules/hall-ui/public/market-watch-settings.html +1 -1
- package/modules/hall-ui/public/market-watch.css +169 -0
- package/modules/hall-ui/public/market-watch.html +111 -0
- package/modules/hall-ui/public/market-watch.js +129 -0
- package/modules/hall-ui/public/market-watch.states.json +14 -0
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/my-agents.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.css +12 -0
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/project-settings.js +63 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +3 -2
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/task.js +34 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/market-watch.md +40 -0
- 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/modules/market-watch/db.js +10 -8
- package/modules/market-watch/module.json +2 -1
- package/modules/market-watch/pages.js +323 -0
- package/modules/market-watch/proposals.js +66 -4
- package/modules/market-watch/routes/pages.js +116 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -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/bongos/module-scope-map.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/fixtures/market-watch-seed-rows.cjs +60 -0
- package/tests/government_act_alone_buildings.mjs +1 -0
- package/tests/hall_nav.mjs +4 -1
- package/tests/hall_page_gate_map.mjs +4 -0
- package/tests/market_watch_module.mjs +2 -1
- package/tests/market_watch_pages.mjs +356 -0
- package/tests/market_watch_rules.mjs +63 -3
- package/tests/nav_permission_atoms.mjs +1 -1
- 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,64 @@
|
|
|
1
|
+
-- lifecycle_018_pay_terms.sql — task 1004990 (goal 1000092; ADR 0385 decision 3
|
|
2
|
+
-- and its 2026-10-29 amendment).
|
|
3
|
+
--
|
|
4
|
+
-- The PROJECT sets the pay, not Bongos. A task can carry zero or more pay terms,
|
|
5
|
+
-- each in the project's own words: a type, an amount and a unit as the project
|
|
6
|
+
-- states them, and a note. A project can also keep standing pay terms that apply
|
|
7
|
+
-- to every task without terms of its own — that is the project's pay policy,
|
|
8
|
+
-- and per the owner's ruling it lives here, in the database, changed on the
|
|
9
|
+
-- project's settings page, never in the branding pack.
|
|
10
|
+
--
|
|
11
|
+
-- A LIFECYCLE MIGRATION, NOT A CORE ONE: these are brand-new tables the lifecycle
|
|
12
|
+
-- module owns (modules/lifecycle/pay-terms.js is their only reader and writer), so
|
|
13
|
+
-- they are lifecycle_-namespaced here, the lifecycle_017_notes precedent — a core
|
|
14
|
+
-- migration is only for a change to a core table.
|
|
15
|
+
--
|
|
16
|
+
-- RECORDS ONLY. Nothing here moves, holds, owes or guarantees money; the amount
|
|
17
|
+
-- is TEXT on purpose, so no code can sum it into a balance (ADR 0387: money stays
|
|
18
|
+
-- with the processor). Bongos suggests no amount: no column has a default.
|
|
19
|
+
--
|
|
20
|
+
-- THE SHAPE IS A HISTORY OF WHOLE SETS. Each change appends one set row (who,
|
|
21
|
+
-- when, which task — NULL for the project's standing terms) and the terms that
|
|
22
|
+
-- set holds. The newest set is current; a set with no terms means "no pay terms".
|
|
23
|
+
-- So the audit trail of who changed what and when is the table itself, and
|
|
24
|
+
-- nothing is ever edited or deleted: a row trigger refuses UPDATE and DELETE on
|
|
25
|
+
-- both tables. (A whole-table wipe is not guarded here: the module-migration gate
|
|
26
|
+
-- keeps module migrations additive and refuses that statement's keyword outright,
|
|
27
|
+
-- and no code path in the tree issues one.)
|
|
28
|
+
|
|
29
|
+
CREATE TABLE IF NOT EXISTS lifecycle_pay_term_sets (
|
|
30
|
+
id bigserial PRIMARY KEY,
|
|
31
|
+
task_id bigint REFERENCES tasks(id), -- NULL = the project's standing terms
|
|
32
|
+
set_by bigint NOT NULL REFERENCES builders(id),
|
|
33
|
+
set_at timestamptz NOT NULL DEFAULT now()
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
-- The current set for a task (or for the project, task_id NULL): newest first.
|
|
37
|
+
CREATE INDEX IF NOT EXISTS lifecycle_pay_term_sets_current
|
|
38
|
+
ON lifecycle_pay_term_sets (task_id, set_at DESC, id DESC);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS lifecycle_pay_terms (
|
|
41
|
+
id bigserial PRIMARY KEY,
|
|
42
|
+
set_id bigint NOT NULL REFERENCES lifecycle_pay_term_sets(id),
|
|
43
|
+
position smallint NOT NULL CHECK (position BETWEEN 0 AND 9),
|
|
44
|
+
pay_type text NOT NULL CHECK (pay_type IN
|
|
45
|
+
('cash', 'equity', 'module_share', 'credit_pool', 'non_monetary', 'self_managed')),
|
|
46
|
+
amount text CHECK (amount IS NULL OR char_length(amount) BETWEEN 1 AND 64),
|
|
47
|
+
unit text CHECK (unit IS NULL OR char_length(unit) BETWEEN 1 AND 32),
|
|
48
|
+
note text CHECK (note IS NULL OR char_length(note) BETWEEN 1 AND 500),
|
|
49
|
+
UNIQUE (set_id, position)
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
CREATE OR REPLACE FUNCTION lifecycle_pay_terms_refuse_change() RETURNS trigger AS $$
|
|
53
|
+
BEGIN
|
|
54
|
+
RAISE EXCEPTION 'pay terms are a history: % on % is refused; set a new set of terms instead', TG_OP, TG_TABLE_NAME
|
|
55
|
+
USING ERRCODE = 'check_violation';
|
|
56
|
+
END;
|
|
57
|
+
$$ LANGUAGE plpgsql;
|
|
58
|
+
|
|
59
|
+
DROP TRIGGER IF EXISTS lifecycle_pay_term_sets_append_only ON lifecycle_pay_term_sets;
|
|
60
|
+
CREATE TRIGGER lifecycle_pay_term_sets_append_only BEFORE UPDATE OR DELETE ON lifecycle_pay_term_sets
|
|
61
|
+
FOR EACH ROW EXECUTE FUNCTION lifecycle_pay_terms_refuse_change();
|
|
62
|
+
DROP TRIGGER IF EXISTS lifecycle_pay_terms_append_only ON lifecycle_pay_terms;
|
|
63
|
+
CREATE TRIGGER lifecycle_pay_terms_append_only BEFORE UPDATE OR DELETE ON lifecycle_pay_terms
|
|
64
|
+
FOR EACH ROW EXECUTE FUNCTION lifecycle_pay_terms_refuse_change();
|
|
@@ -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 =
|
|
@@ -130,14 +130,16 @@ const FOUND_IN_SQL = `
|
|
|
130
130
|
JOIN market_watch_items i ON i.id = e.item_id
|
|
131
131
|
WHERE i.project = $1 AND e.run_id IS NOT NULL AND ($2::bigint IS NULL OR e.item_id = $2)`;
|
|
132
132
|
|
|
133
|
+
// `knownRuns` (here, in getItem, caseFile and scoreHistory): the numbered runs when the caller
|
|
134
|
+
// already read them, so one page load reads market_watch_runs once (task 1004939).
|
|
133
135
|
// listItems — the home desk: every tracked item with its latest overall score, last
|
|
134
136
|
// finding, level and streak.
|
|
135
|
-
async function listItems(pool, { project, kind = null, includeRemoved = false, id = null } = {}) {
|
|
137
|
+
async function listItems(pool, { project, kind = null, includeRemoved = false, id = null, knownRuns = null } = {}) {
|
|
136
138
|
need(project);
|
|
137
139
|
const [{ rows: items }, { rows: found }, runs] = await Promise.all([
|
|
138
140
|
pool.query(ITEMS_SQL, [project, kind, includeRemoved, id]),
|
|
139
141
|
pool.query(FOUND_IN_SQL, [project, id]),
|
|
140
|
-
numberedRuns(pool, { project }),
|
|
142
|
+
knownRuns || numberedRuns(pool, { project }),
|
|
141
143
|
]);
|
|
142
144
|
const byItem = new Map();
|
|
143
145
|
for (const f of found) {
|
|
@@ -154,8 +156,8 @@ async function listItems(pool, { project, kind = null, includeRemoved = false, i
|
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
// getItem — one item, with removed ones included (its page still opens).
|
|
157
|
-
async function getItem(pool, { project, id }) {
|
|
158
|
-
const rows = await listItems(pool, { project, includeRemoved: true, id });
|
|
159
|
+
async function getItem(pool, { project, id, knownRuns = null }) {
|
|
160
|
+
const rows = await listItems(pool, { project, includeRemoved: true, id, knownRuns });
|
|
159
161
|
return rows[0] || null;
|
|
160
162
|
}
|
|
161
163
|
|
|
@@ -168,11 +170,11 @@ const CASE_FILE_SQL = `
|
|
|
168
170
|
ORDER BY e.pinned DESC, e.found_on DESC, e.id DESC`;
|
|
169
171
|
|
|
170
172
|
// caseFile — the item page's sortable table: found, run, type, takeaway, confidence, source.
|
|
171
|
-
async function caseFile(pool, { project, itemId }) {
|
|
173
|
+
async function caseFile(pool, { project, itemId, knownRuns = null }) {
|
|
172
174
|
need(project);
|
|
173
175
|
const [{ rows }, runs] = await Promise.all([
|
|
174
176
|
pool.query(CASE_FILE_SQL, [project, itemId]),
|
|
175
|
-
numberedRuns(pool, { project }),
|
|
177
|
+
knownRuns || numberedRuns(pool, { project }),
|
|
176
178
|
]);
|
|
177
179
|
const runN = new Map(runs.map((r) => [String(r.id), r.n]));
|
|
178
180
|
return rows.map((e) => ({ ...e, id: Number(e.id), run: e.run_id == null ? null : runN.get(String(e.run_id)) ?? null }));
|
|
@@ -185,11 +187,11 @@ const SCORES_SQL = `
|
|
|
185
187
|
WHERE i.project = $1 AND s.item_id = $2`;
|
|
186
188
|
|
|
187
189
|
// scoreHistory — the item page's sparklines, one per dimension plus 'overall'.
|
|
188
|
-
async function scoreHistory(pool, { project, itemId }) {
|
|
190
|
+
async function scoreHistory(pool, { project, itemId, knownRuns = null }) {
|
|
189
191
|
need(project);
|
|
190
192
|
const [{ rows }, runs] = await Promise.all([
|
|
191
193
|
pool.query(SCORES_SQL, [project, itemId]),
|
|
192
|
-
numberedRuns(pool, { project }),
|
|
194
|
+
knownRuns || numberedRuns(pool, { project }),
|
|
193
195
|
]);
|
|
194
196
|
return sparklines(rows, runs);
|
|
195
197
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
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), 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), 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) and the owner's settings (on/off, monthly cap, cadence, the project key; task 1004938) 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.",
|
|
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), its home and tracked-item pages (task 1004939), 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) and the owner's settings (on/off, monthly cap, cadence, the project key; task 1004938) 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
5
|
"version": "0.4.0",
|
|
6
6
|
"coreVersion": "^1.22.0",
|
|
7
7
|
"category": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"migrations": true,
|
|
16
16
|
"routes": [
|
|
17
17
|
"ask",
|
|
18
|
+
"pages",
|
|
18
19
|
"rules",
|
|
19
20
|
"settings"
|
|
20
21
|
],
|