@bongos/core 1.24.9 → 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.
Files changed (91) hide show
  1. package/.bongos-core.json +146 -86
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +14 -0
  4. package/clients/bongos-client/index.cjs +14 -0
  5. package/clients/bongos-client/index.d.ts +20 -1
  6. package/clients/bongos-client/index.mjs +14 -0
  7. package/docs/api/openapi.json +308 -5
  8. package/docs/api-reference.md +15 -4
  9. package/docs/architecture.md +4 -0
  10. package/docs/copy-inventory.md +109 -75
  11. package/docs/copy-registry.json +425 -89
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-inventory.json +34 -4
  14. package/docs/page-readings.json +301 -244
  15. package/modules/government/decision-rules.js +1 -0
  16. package/modules/hall-ui/public/ai-studio.html +2 -2
  17. package/modules/hall-ui/public/atlas.html +2 -2
  18. package/modules/hall-ui/public/blockers.html +2 -2
  19. package/modules/hall-ui/public/board-room.html +2 -2
  20. package/modules/hall-ui/public/city.html +2 -2
  21. package/modules/hall-ui/public/collab.html +2 -2
  22. package/modules/hall-ui/public/commands.html +2 -2
  23. package/modules/hall-ui/public/copy-desk.html +2 -2
  24. package/modules/hall-ui/public/craft-mode.js +1 -1
  25. package/modules/hall-ui/public/deploy.html +2 -2
  26. package/modules/hall-ui/public/diagrams.html +2 -2
  27. package/modules/hall-ui/public/drachmae.html +2 -2
  28. package/modules/hall-ui/public/fleet.html +2 -2
  29. package/modules/hall-ui/public/gate.html +2 -2
  30. package/modules/hall-ui/public/goal-map.html +2 -2
  31. package/modules/hall-ui/public/goals.html +2 -2
  32. package/modules/hall-ui/public/government.html +2 -2
  33. package/modules/hall-ui/public/idea.html +2 -2
  34. package/modules/hall-ui/public/ideas.html +2 -2
  35. package/modules/hall-ui/public/index.html +2 -2
  36. package/modules/hall-ui/public/market-watch-rules.html +2 -2
  37. package/modules/hall-ui/public/market-watch-settings.css +217 -0
  38. package/modules/hall-ui/public/market-watch-settings.html +113 -0
  39. package/modules/hall-ui/public/market-watch-settings.js +230 -0
  40. package/modules/hall-ui/public/market-watch-settings.states.json +14 -0
  41. package/modules/hall-ui/public/modules.html +2 -2
  42. package/modules/hall-ui/public/my-agents.html +2 -2
  43. package/modules/hall-ui/public/primer.html +2 -2
  44. package/modules/hall-ui/public/profile.html +2 -2
  45. package/modules/hall-ui/public/project-settings.css +12 -0
  46. package/modules/hall-ui/public/project-settings.html +2 -2
  47. package/modules/hall-ui/public/project-settings.js +63 -1
  48. package/modules/hall-ui/public/ranks.html +2 -2
  49. package/modules/hall-ui/public/roadmap.html +2 -2
  50. package/modules/hall-ui/public/roster.html +2 -2
  51. package/modules/hall-ui/public/sessions.html +2 -2
  52. package/modules/hall-ui/public/settings.html +2 -2
  53. package/modules/hall-ui/public/shell.js +3 -0
  54. package/modules/hall-ui/public/street.html +2 -2
  55. package/modules/hall-ui/public/studio.html +2 -2
  56. package/modules/hall-ui/public/task.html +2 -2
  57. package/modules/hall-ui/public/task.js +34 -1
  58. package/modules/hall-ui/public/thinking.html +2 -2
  59. package/modules/hall-ui/public/tweak-editor.html +2 -2
  60. package/modules/hall-ui/public/watch.html +2 -2
  61. package/modules/hall-ui/public/work.html +2 -2
  62. package/modules/hall-ui/records/market-watch-settings.md +11 -0
  63. package/modules/lifecycle/migrations/lifecycle_018_pay_terms.sql +64 -0
  64. package/modules/lifecycle/pay-terms.js +171 -0
  65. package/modules/lifecycle/routes/project-settings.js +94 -0
  66. package/modules/lifecycle/routes/task-write-routes.js +35 -4
  67. package/modules/market-watch/module.json +3 -2
  68. package/modules/market-watch/routes/settings.js +128 -0
  69. package/modules/market-watch/settings.js +207 -0
  70. package/modules/provisioning/starter-bundles.js +11 -6
  71. package/modules/ui-design/kit/serve.js +1 -0
  72. package/package-lock.json +2 -2
  73. package/package.json +1 -1
  74. package/release-notes.json +12 -0
  75. package/scripts/bongos/government-walk.js +1 -0
  76. package/scripts/bongos/run-unit-tests.js +4 -0
  77. package/scripts/bongos/start.js +42 -2
  78. package/src/bongos/module-scope-map.js +1 -0
  79. package/src/bongos/serve-internal.js +2 -2
  80. package/src/module-api.js +1 -1
  81. package/tests/government_act_alone_buildings.mjs +1 -0
  82. package/tests/hall_nav.mjs +4 -2
  83. package/tests/hall_page_gate_map.mjs +4 -0
  84. package/tests/market_watch_data_db.mjs +27 -0
  85. package/tests/market_watch_module.mjs +8 -4
  86. package/tests/market_watch_settings.mjs +392 -0
  87. package/tests/nav_permission_atoms.mjs +3 -2
  88. package/tests/patch_gate_ordering.mjs +3 -0
  89. package/tests/pay_terms.mjs +254 -0
  90. package/tests/pay_terms_db.mjs +86 -0
  91. package/tests/project_settings_page.mjs +25 -6
@@ -165,8 +165,8 @@
165
165
  chrome exists before the page script runs. -->
166
166
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
167
167
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
168
- <script src="/builders/craft-mode.js?v=2026-10-09-living3"></script>
169
- <script src="/builders/shell.js?v=2026-10-25-escape"></script>
168
+ <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
169
+ <script src="/builders/shell.js?v=2026-10-30-market-watch"></script>
170
170
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
171
171
  <script src="/builders/palette.js" defer></script>
172
172
  <!-- shared component kit (window.OTBKit) — tabs, ledger, pager (task 2166). -->
@@ -173,8 +173,8 @@
173
173
  time so the chrome exists before the deferred page scripts execute. -->
174
174
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
175
175
  <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
176
- <script src="/builders/craft-mode.js?v=2026-10-09-living3"></script>
177
- <script src="/builders/shell.js?v=2026-10-25-escape"></script>
176
+ <script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
177
+ <script src="/builders/shell.js?v=2026-10-30-market-watch"></script>
178
178
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
179
179
  <script src="/builders/palette.js" defer></script>
180
180
  <!-- task 1004727: the ONE karma vote control every task/idea list renders -->
@@ -0,0 +1,11 @@
1
+ # Market Watch settings, at /market-watch-settings (task 1004938)
2
+
3
+ **The owner's fuel gauge for Market Watch (goal 1000137), built to `docs/design/mocks/market-watch/settings.html`, option B.** One panel: the gauge (spend this month against the monthly AI cap, with a faint arc for what the next run will use), the on/off pill, the cap slider, the cadence chips (weekly, every 2 weeks, monthly), the strip of recent runs, and the project's Anthropic key. Five things it settled:
4
+
5
+ - **The routes are the wall, not the page.** `GET`/`PATCH /market-watch/settings` and `PUT`/`DELETE /market-watch/settings/key` are owner-only: the `project.settings.manage` atom, then an in-route owner-seat (Archon) check (modules/market-watch/routes/settings.js). The shell joins `SIGNEDIN_BUILDERS_PAGE_RE` and holds no data; a 403 draws the owner-only panel and a 404 (module off, so its routes are not mounted) the "turn the module on" panel. The nav item sits in Government, `archon`, `module: 'market-watch'`.
6
+ - **The key is write-only.** The page learns `key_set` and nothing else, not the last four. The field is emptied before the request goes out; Clear asks twice by turning into its own question.
7
+ - **The page redraws from the server's answer.** Every write returns the whole view, so the gauge never shows a value the server did not store.
8
+ - **The strip is the run ledger, oldest left.** A ran run shows the names it watched; a skipped one is a hatched dash, with its reason in the cell's label and the legend (`cap reached`, `switched off`, `no key`, …); the dashed tile is the next run and the two after it are empty. When the watch is off the future tiles go. The seed run never shows. At 620px and below the dates stack day over month, or nine tiles overflow at 390px.
9
+ - **Mockup colours were not kept.** The ring, chips and tiles use the hall's `--accent`, `--accent-tint`, `--accent-text`, `--rule*` and `--ok-weak`; the mock's own fonts were dropped for the pack's.
10
+
11
+ Fixture: `scripts/hall-preview/fixtures/market-watch__settings.json` (the mockup's month). Tests: `tests/market_watch_settings.mjs` (403s for a Metic and a Xenos on all four routes, switching off stops the next run against `run.js`'s own gate, the key never in a response).
@@ -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 =
@@ -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) and its rules page (the watch rules in plain words, members' proposed changes, and the owner's or the government's decision on each, task 1004941) are in. Off by default, and it cannot be turned on behind an open join door with no payer, because every run will spend the project's AI budget.",
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.",
5
5
  "version": "0.4.0",
6
6
  "coreVersion": "^1.22.0",
7
7
  "category": {
@@ -15,7 +15,8 @@
15
15
  "migrations": true,
16
16
  "routes": [
17
17
  "ask",
18
- "rules"
18
+ "rules",
19
+ "settings"
19
20
  ],
20
21
  "pollers": [
21
22
  "hub-seed",
@@ -0,0 +1,128 @@
1
+ // modules/market-watch/routes/settings.js — the owner's Market Watch settings (task 1004938,
2
+ // goal 1000137). The page is the hall's /market-watch-settings, built to
3
+ // docs/design/mocks/market-watch/settings.html (option B, the fuel gauge).
4
+ //
5
+ // GET /market-watch/settings the switch, cap, cadence, this month's spend, the next
6
+ // run, recent runs, and whether a key is set
7
+ // PATCH /market-watch/settings change any of { enabled, monthly_cap_usd, cadence }
8
+ // PUT /market-watch/settings/key save or replace the project's Anthropic key { key }
9
+ // DELETE /market-watch/settings/key clear it
10
+ //
11
+ // rank: archon — the owner seat. Two server-side gates on every route, the read included
12
+ // (ADR 0016; the page hiding a control is never the wall):
13
+ // 1. requirePermission('project.settings.manage') — the catalog atom for "the project's
14
+ // own live knobs" (modules/government/catalog.js, floor metic). Module routes gate on
15
+ // atoms, never on a rank gate (tests/government_require_permission.mjs).
16
+ // 2. ownerSeat — the owner seat is every active Archon (government leads.ownerOf), and
17
+ // the task says owner-only, so a Metic who holds the atom is still refused 403
18
+ // rank_forbidden. No archon-floor atom names this authority, and borrowing an
19
+ // unrelated one would name the wrong authority (the scouting holdout's reasoning in
20
+ // that test). The security_sensitive check in lifecycle's task-write-routes is the
21
+ // precedent for an archon check behind a wider atom.
22
+ // The read is owner-only too: it carries the project's AI spend and whether its key is set.
23
+ //
24
+ // The key is write-only. No route returns it or any part of it; the body field is named
25
+ // `key`, which the audit middleware redacts, and no log line here carries the body.
26
+
27
+ 'use strict';
28
+
29
+ const express = require('express');
30
+ const api = require('../../../src/module-api');
31
+ const settings = require('../settings');
32
+ const { projectKey } = require('../seed');
33
+
34
+ const log = api.logger('market-watch');
35
+
36
+ const MAX_KEY_BODY = 512;
37
+ const KEY_MESSAGES = {
38
+ key_required: 'Paste the key.',
39
+ key_wrong_length: 'That does not look like an Anthropic key. Paste the whole key.',
40
+ key_has_spaces: 'That does not look like an Anthropic key. Paste it with no spaces.',
41
+ };
42
+
43
+ // The project is this instance's own public origin, as the run and the seed read it.
44
+ function currentProject() {
45
+ try { return projectKey(api.branding().domains.publicOrigin); } catch (_) { return null; }
46
+ }
47
+
48
+ function ceilingUsd() {
49
+ try { return require('../research').itemCeilingUsd(); } catch (_) { return null; }
50
+ }
51
+
52
+ function storageConfigured() {
53
+ try { return api.secretBox.isConfigured() === true; } catch (_) { return false; }
54
+ }
55
+
56
+ module.exports = function buildMarketWatchSettingsRouter(deps = {}) {
57
+ const router = express.Router();
58
+ // Seams for the tests: the pool, the project and the box. Production passes nothing.
59
+ const pool = () => deps.pool || api.pool;
60
+ const project = () => (deps.project !== undefined ? deps.project : currentProject());
61
+ const box = () => deps.secretBox || api.secretBox;
62
+ const isStorageConfigured = () => (deps.secretBox ? deps.secretBox.isConfigured() === true : storageConfigured());
63
+
64
+ // The owner seat, checked after the atom: holding project.settings.manage is not enough.
65
+ function ownerSeat(req, res, next) {
66
+ const actual = req.builder && req.builder.rank;
67
+ if (!api.rankMeetsThreshold(actual, ['archon'])) {
68
+ return res.fail('rank_forbidden', 403, { required: ['archon'], actual: actual || null, reason: 'Only the project owner can see or change Market Watch settings.' });
69
+ }
70
+ return next();
71
+ }
72
+
73
+ function withProject(res) {
74
+ const p = project();
75
+ if (!p) res.fail('project_origin_unset', { status: 409, message: 'This project has no public address set, so Market Watch cannot tell which project it is.' });
76
+ return p;
77
+ }
78
+
79
+ async function view(p) {
80
+ return settings.readView(pool(), p, {
81
+ now: deps.now ? new Date(deps.now()) : new Date(),
82
+ ceilingUsd: deps.ceilingUsd !== undefined ? deps.ceilingUsd : ceilingUsd(),
83
+ storageConfigured: isStorageConfigured(),
84
+ });
85
+ }
86
+
87
+ router.get('/market-watch/settings', api.requireBuilder, api.requirePermission('project.settings.manage'), ownerSeat, api.asyncHandler('GET /market-watch/settings', async (req, res) => {
88
+ const p = withProject(res);
89
+ if (!p) return;
90
+ res.json(await view(p));
91
+ }));
92
+
93
+ router.patch('/market-watch/settings', api.requireBuilder, api.requirePermission('project.settings.manage'), ownerSeat, api.asyncHandler('PATCH /market-watch/settings', async (req, res) => {
94
+ // Strict field list (unknown fields refused by the envelope); the values are checked by
95
+ // settings.parsePatch, the one validator the tests also run.
96
+ if (api.validateOrRespond(req, res, { enabled: {}, monthly_cap_usd: {}, cadence: {} })) return;
97
+ const parsed = settings.parsePatch(req.body);
98
+ if (parsed.error) return res.fail(parsed.error, { status: 400, message: parsed.message });
99
+ const p = withProject(res);
100
+ if (!p) return;
101
+ const saved = await settings.writeSettings(pool(), p, parsed.patch, req.builder.id);
102
+ log.info({ builder_id: req.builder.id, fields: Object.keys(parsed.patch), enabled: saved.enabled }, 'market watch settings changed');
103
+ res.json(await view(p));
104
+ }));
105
+
106
+ router.put('/market-watch/settings/key', api.requireBuilder, api.requirePermission('project.settings.manage'), ownerSeat, api.asyncHandler('PUT /market-watch/settings/key', async (req, res) => {
107
+ if (api.validateOrRespond(req, res, { key: { required: true, type: 'string', maxLength: MAX_KEY_BODY } })) return;
108
+ const p = withProject(res);
109
+ if (!p) return;
110
+ if (!isStorageConfigured()) {
111
+ return res.fail('storage_not_configured', { status: 409, message: 'Key storage is not set up on this project yet.' });
112
+ }
113
+ const out = await settings.setKey(pool(), p, req.body.key, req.builder.id, { encrypt: (s) => box().encrypt(s) });
114
+ if (out.error) return res.fail(out.error, { status: 400, message: KEY_MESSAGES[out.error] || 'That key could not be saved.' });
115
+ log.info({ builder_id: req.builder.id }, 'market watch key saved');
116
+ res.json(await view(p));
117
+ }));
118
+
119
+ router.delete('/market-watch/settings/key', api.requireBuilder, api.requirePermission('project.settings.manage'), ownerSeat, api.asyncHandler('DELETE /market-watch/settings/key', async (req, res) => {
120
+ const p = withProject(res);
121
+ if (!p) return;
122
+ const out = await settings.clearKey(pool(), p, req.builder.id);
123
+ log.info({ builder_id: req.builder.id, removed: out.removed }, 'market watch key cleared');
124
+ res.json(await view(p));
125
+ }));
126
+
127
+ return router;
128
+ };