@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.
Files changed (96) hide show
  1. package/.bongos-core.json +171 -86
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +12 -0
  4. package/clients/bongos-client/index.cjs +12 -0
  5. package/clients/bongos-client/index.d.ts +18 -1
  6. package/clients/bongos-client/index.mjs +12 -0
  7. package/docs/api/openapi.json +304 -5
  8. package/docs/api-reference.md +14 -4
  9. package/docs/architecture.md +4 -0
  10. package/docs/copy-inventory.md +154 -76
  11. package/docs/copy-registry.json +923 -125
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-inventory.json +68 -4
  14. package/docs/page-readings.json +873 -819
  15. package/modules/government/decision-rules.js +1 -0
  16. package/modules/hall-ui/public/ai-studio.html +1 -1
  17. package/modules/hall-ui/public/atlas.html +1 -1
  18. package/modules/hall-ui/public/blockers.html +1 -1
  19. package/modules/hall-ui/public/board-room.html +1 -1
  20. package/modules/hall-ui/public/city.html +1 -1
  21. package/modules/hall-ui/public/collab.html +1 -1
  22. package/modules/hall-ui/public/commands.html +1 -1
  23. package/modules/hall-ui/public/copy-desk.html +1 -1
  24. package/modules/hall-ui/public/craft-mode.js +2 -2
  25. package/modules/hall-ui/public/deploy.html +1 -1
  26. package/modules/hall-ui/public/diagrams.html +1 -1
  27. package/modules/hall-ui/public/drachmae.html +1 -1
  28. package/modules/hall-ui/public/fleet.html +1 -1
  29. package/modules/hall-ui/public/gate.html +1 -1
  30. package/modules/hall-ui/public/goal-map.html +1 -1
  31. package/modules/hall-ui/public/goals.html +1 -1
  32. package/modules/hall-ui/public/government.html +1 -1
  33. package/modules/hall-ui/public/idea.html +1 -1
  34. package/modules/hall-ui/public/ideas.html +1 -1
  35. package/modules/hall-ui/public/index.html +1 -1
  36. package/modules/hall-ui/public/market-watch-item.html +83 -0
  37. package/modules/hall-ui/public/market-watch-item.js +150 -0
  38. package/modules/hall-ui/public/market-watch-item.states.json +143 -0
  39. package/modules/hall-ui/public/market-watch-lib.js +243 -0
  40. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  41. package/modules/hall-ui/public/market-watch-settings.html +1 -1
  42. package/modules/hall-ui/public/market-watch.css +169 -0
  43. package/modules/hall-ui/public/market-watch.html +111 -0
  44. package/modules/hall-ui/public/market-watch.js +129 -0
  45. package/modules/hall-ui/public/market-watch.states.json +14 -0
  46. package/modules/hall-ui/public/modules.html +1 -1
  47. package/modules/hall-ui/public/my-agents.html +1 -1
  48. package/modules/hall-ui/public/primer.html +1 -1
  49. package/modules/hall-ui/public/profile.html +1 -1
  50. package/modules/hall-ui/public/project-settings.css +12 -0
  51. package/modules/hall-ui/public/project-settings.html +1 -1
  52. package/modules/hall-ui/public/project-settings.js +63 -1
  53. package/modules/hall-ui/public/ranks.html +1 -1
  54. package/modules/hall-ui/public/roadmap.html +1 -1
  55. package/modules/hall-ui/public/roster.html +1 -1
  56. package/modules/hall-ui/public/sessions.html +1 -1
  57. package/modules/hall-ui/public/settings.html +1 -1
  58. package/modules/hall-ui/public/shell.js +3 -2
  59. package/modules/hall-ui/public/street.html +1 -1
  60. package/modules/hall-ui/public/studio.html +1 -1
  61. package/modules/hall-ui/public/task.html +1 -1
  62. package/modules/hall-ui/public/task.js +34 -1
  63. package/modules/hall-ui/public/thinking.html +1 -1
  64. package/modules/hall-ui/public/tweak-editor.html +1 -1
  65. package/modules/hall-ui/public/watch.html +1 -1
  66. package/modules/hall-ui/public/work.html +1 -1
  67. package/modules/hall-ui/records/market-watch.md +40 -0
  68. package/modules/lifecycle/migrations/lifecycle_018_pay_terms.sql +64 -0
  69. package/modules/lifecycle/pay-terms.js +171 -0
  70. package/modules/lifecycle/routes/project-settings.js +94 -0
  71. package/modules/lifecycle/routes/task-write-routes.js +35 -4
  72. package/modules/market-watch/db.js +10 -8
  73. package/modules/market-watch/module.json +2 -1
  74. package/modules/market-watch/pages.js +323 -0
  75. package/modules/market-watch/proposals.js +66 -4
  76. package/modules/market-watch/routes/pages.js +116 -0
  77. package/package-lock.json +2 -2
  78. package/package.json +1 -1
  79. package/release-notes.json +12 -0
  80. package/scripts/bongos/government-walk.js +1 -0
  81. package/scripts/bongos/run-unit-tests.js +4 -0
  82. package/scripts/bongos/start.js +42 -2
  83. package/src/bongos/module-scope-map.js +1 -0
  84. package/src/module-api.js +1 -1
  85. package/tests/fixtures/market-watch-seed-rows.cjs +60 -0
  86. package/tests/government_act_alone_buildings.mjs +1 -0
  87. package/tests/hall_nav.mjs +4 -1
  88. package/tests/hall_page_gate_map.mjs +4 -0
  89. package/tests/market_watch_module.mjs +2 -1
  90. package/tests/market_watch_pages.mjs +356 -0
  91. package/tests/market_watch_rules.mjs +63 -3
  92. package/tests/nav_permission_atoms.mjs +1 -1
  93. package/tests/patch_gate_ordering.mjs +3 -0
  94. package/tests/pay_terms.mjs +254 -0
  95. package/tests/pay_terms_db.mjs +86 -0
  96. 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
- if (!hasParent) return res.json({ task: updated });
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
  ],