@bongos/core 1.21.35 → 1.21.37

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 (78) hide show
  1. package/.bongos-core.json +100 -80
  2. package/clients/bongos-client/index.d.ts +3 -3
  3. package/docs/api/openapi.json +25 -35
  4. package/docs/api-reference.md +1 -1
  5. package/docs/copy-inventory.md +8 -8
  6. package/docs/copy-registry.json +8 -8
  7. package/docs/module-api-changelog.md +4 -0
  8. package/docs/page-inventory.json +4 -1
  9. package/docs/page-readings.json +569 -523
  10. package/modules/autonomy/matter-acts.js +41 -0
  11. package/modules/autonomy/routes/autonomy.js +2 -0
  12. package/modules/government/board.js +4 -0
  13. package/modules/government/db.js +14 -0
  14. package/modules/government/decision-board.js +90 -11
  15. package/modules/government/docket-vocab.js +5 -0
  16. package/modules/government/docket.js +11 -0
  17. package/modules/government/routes/government.js +2 -1
  18. package/modules/hall-ui/public/atlas.html +3 -3
  19. package/modules/hall-ui/public/blockers.html +3 -3
  20. package/modules/hall-ui/public/board-room.html +3 -3
  21. package/modules/hall-ui/public/city.html +3 -3
  22. package/modules/hall-ui/public/collab.html +3 -3
  23. package/modules/hall-ui/public/commands.html +3 -3
  24. package/modules/hall-ui/public/copy-desk.html +3 -3
  25. package/modules/hall-ui/public/craft-mode.js +44 -6
  26. package/modules/hall-ui/public/deploy.html +3 -3
  27. package/modules/hall-ui/public/diagrams.html +3 -3
  28. package/modules/hall-ui/public/drachmae.html +3 -3
  29. package/modules/hall-ui/public/fleet.html +3 -3
  30. package/modules/hall-ui/public/gate.html +3 -3
  31. package/modules/hall-ui/public/goal-map.html +3 -3
  32. package/modules/hall-ui/public/goals.html +3 -3
  33. package/modules/hall-ui/public/government.html +3 -3
  34. package/modules/hall-ui/public/idea.html +3 -3
  35. package/modules/hall-ui/public/ideas.html +3 -3
  36. package/modules/hall-ui/public/index.html +3 -3
  37. package/modules/hall-ui/public/modules.html +3 -3
  38. package/modules/hall-ui/public/primer.html +3 -3
  39. package/modules/hall-ui/public/profile.html +3 -3
  40. package/modules/hall-ui/public/project-settings.html +3 -3
  41. package/modules/hall-ui/public/ranks.html +3 -3
  42. package/modules/hall-ui/public/roadmap.html +3 -3
  43. package/modules/hall-ui/public/roster.html +3 -3
  44. package/modules/hall-ui/public/sessions.html +3 -3
  45. package/modules/hall-ui/public/settings.html +3 -3
  46. package/modules/hall-ui/public/street-card.js +62 -2
  47. package/modules/hall-ui/public/street-desk.js +101 -8
  48. package/modules/hall-ui/public/street-inside.js +6 -3
  49. package/modules/hall-ui/public/street.css +15 -0
  50. package/modules/hall-ui/public/street.html +3 -3
  51. package/modules/hall-ui/public/street.js +73 -4
  52. package/modules/hall-ui/public/street.states.json +124 -0
  53. package/modules/hall-ui/public/studio.html +3 -3
  54. package/modules/hall-ui/public/style.css +121 -2
  55. package/modules/hall-ui/public/task.html +3 -3
  56. package/modules/hall-ui/public/thinking.html +3 -3
  57. package/modules/hall-ui/public/tweak-editor.html +3 -3
  58. package/modules/hall-ui/public/watch.html +3 -3
  59. package/modules/hall-ui/public/work.html +3 -3
  60. package/modules/hall-ui/records/craft-mode.md +8 -0
  61. package/modules/ideas/governor-docket.js +91 -0
  62. package/modules/lifecycle/governor-docket.js +69 -0
  63. package/modules/lifecycle/matter-acts.js +63 -0
  64. package/modules/lifecycle/routes/done-when.js +26 -12
  65. package/modules/lifecycle/routes/gate-approvals.js +74 -59
  66. package/modules/lifecycle/routes/lifecycle.js +2 -0
  67. package/package-lock.json +2 -2
  68. package/package.json +1 -1
  69. package/release-notes.json +16 -0
  70. package/src/bongos/route-rank-check.js +2 -0
  71. package/src/bongos/routes/builders.js +56 -1
  72. package/src/module-api.js +1 -1
  73. package/tests/craft_mode_looks.mjs +170 -0
  74. package/tests/craft_mode_nav.mjs +7 -5
  75. package/tests/governor_matter_acts.mjs +241 -0
  76. package/tests/hall_street_desk.mjs +244 -4
  77. package/tests/hall_street_inside.mjs +3 -3
  78. package/tests/hall_tweak_editor.mjs +1 -1
@@ -181,6 +181,37 @@ function prioritiesFloor(goals, versionId) {
181
181
  return { count: '', line: 'the order goals are worked in', lines: [[top, versionId ? `version ${versionId}` : ''], [`${plural(mine.length, 'goal is', 'goals are')} in the order.`, '']], hot: false };
182
182
  }
183
183
 
184
+ // The Priorities desk (task 1004560): the building version's open goals in their
185
+ // worked order, each with its promises kept of total, for a viewer who may set
186
+ // the order — POST /goals/reorder's own atom (goal.reorder), or, where Planning
187
+ // is put to a sitting, the right to file the matter (board.item.open). Which of
188
+ // the two a save takes is the decision table's call, read by the screen from the
189
+ // docket's own buildings row; the route the save reaches still decides. Pure.
190
+ const ORDER_PIPS = 12;
191
+ function orderDeskRows(goals, criteria, versionId, mayOrder) {
192
+ if (!mayOrder || !versionId) return [];
193
+ const by = new Map();
194
+ for (const c of criteria || []) {
195
+ if (c.goal_id == null) continue;
196
+ const k = String(c.goal_id);
197
+ if (!by.has(k)) by.set(k, { kept: 0, total: 0 });
198
+ const e = by.get(k); e.total += 1; if (c.satisfied === true) e.kept += 1;
199
+ }
200
+ const mine = (goals || []).filter((g) => g && g.status === 'open' && g.version_id === versionId);
201
+ if (mine.length < 2) return []; // one goal has no order to set
202
+ return mine.map((g) => {
203
+ const e = by.get(String(g.id));
204
+ const of = e ? Math.min(e.total, ORDER_PIPS) : 0;
205
+ return {
206
+ id: g.id, name: String(g.title), ref: { type: 'goal', id: g.id }, set: String(versionId),
207
+ fact: e ? `${e.kept} of ${e.total} promises kept` : 'no promises yet',
208
+ facts: [`version ${versionId}`],
209
+ ...(of ? { pips: { on: Math.min(e.kept, of), of } } : {}),
210
+ acts: [{ key: 'up' }, { key: 'down' }],
211
+ };
212
+ });
213
+ }
214
+
184
215
  // Planning · Versions: what is building, and the last that shipped.
185
216
  function versionsFloor(versions, goals) {
186
217
  const all = versions || [];
@@ -210,6 +241,26 @@ function signedOffFloor(criteria, now) {
210
241
  };
211
242
  }
212
243
 
244
+ // The Review desk's signed-off rows (task 1004561): the newest promises signed
245
+ // off, each with one act, undo, for a viewer holding criterion.unsatisfy (POST
246
+ // /done-when/:id/unsatisfy's own atom). The reason is one tap. Pure.
247
+ const UNDO_REASONS = Object.freeze([
248
+ { key: 'broke', label: 'it broke again' },
249
+ { key: 'untested', label: 'not truly tested' },
250
+ { key: 'error', label: 'signed in error' },
251
+ ]);
252
+ const SIGNED_ROWS = 8;
253
+ function signedDeskRows(criteria) {
254
+ return (criteria || []).filter((c) => c && c.satisfied === true && c.checked_at)
255
+ .sort((a, b) => Date.parse(b.checked_at) - Date.parse(a.checked_at))
256
+ .slice(0, SIGNED_ROWS)
257
+ .map((c) => ({
258
+ id: c.id, name: String(c.criterion_md), fact: `signed off ${dayWord(c.checked_at)}`,
259
+ facts: c.goal_title ? [String(c.goal_title)] : [], ref: { type: 'signed_criterion', id: c.id },
260
+ acts: [{ key: 'unsign', options: UNDO_REASONS }],
261
+ }));
262
+ }
263
+
213
264
  // Release · Live this month: shipped work, newest first.
214
265
  function liveMonthFloor(shipped, now) {
215
266
  const month = (shipped || []).filter((t) => t.shipped_at && Date.parse(t.shipped_at) >= monthStart(now));
@@ -284,6 +335,15 @@ function contributeLifecycleFloors() {
284
335
  floor: 'planning.priorities', gate: any,
285
336
  read: async (_ctx, { once } = {}) => prioritiesFloor(await openGoals(once), await db.currentBuildingVersionId()),
286
337
  });
338
+ api.contribute(POINT, {
339
+ desk: 'planning.order', gate: any,
340
+ read: async (ctx, { once } = {}) => {
341
+ const may = ctx.has('goal.reorder') || ctx.has('board.item.open');
342
+ if (!may) return [];
343
+ const v = await db.currentBuildingVersionId();
344
+ return orderDeskRows(await openGoals(once), v ? await doneWhen.listCriteria(v) : [], v, may);
345
+ },
346
+ });
287
347
  api.contribute(POINT, {
288
348
  floor: 'planning.versions', gate: any,
289
349
  read: async () => versionsFloor(await db.listVersions(), await db.listGoals({})),
@@ -295,6 +355,13 @@ function contributeLifecycleFloors() {
295
355
  return signedOffFloor(v ? await doneWhen.listCriteria(v) : [], now);
296
356
  },
297
357
  });
358
+ api.contribute(POINT, {
359
+ desk: 'review.signed', gate: { permission: 'criterion.unsatisfy' },
360
+ read: async () => {
361
+ const v = await db.currentBuildingVersionId();
362
+ return signedDeskRows(v ? await doneWhen.listCriteria(v) : []);
363
+ },
364
+ });
298
365
  api.contribute(POINT, {
299
366
  floor: 'release.live_month', gate: any,
300
367
  read: async (_ctx, { now } = {}) => liveMonthFloor(await db.recentShippedTasks(SHIPPED_LIMIT, { days: 31 }), now),
@@ -398,8 +465,10 @@ module.exports = {
398
465
  promisesReading,
399
466
  goalsFloor,
400
467
  prioritiesFloor,
468
+ orderDeskRows,
401
469
  versionsFloor,
402
470
  signedOffFloor,
471
+ signedDeskRows,
403
472
  liveMonthFloor,
404
473
  heldBackFloor,
405
474
  newcomersFloor,
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/matter-acts.js — what a PASSED Review or Release matter
4
+ // carries out (task 1004561, goal 1000125). When a project's decision table puts
5
+ // Review or Release to a sitting, its passed matter acts here, after the close
6
+ // commits, through the SAME function the direct route calls:
7
+ // review satisfy_criterion → satisfyByOverride (POST /done-when/:id/satisfy)
8
+ // release approve_override → db.decideOverrideRequest (POST /override-requests/:id/decide)
9
+ // release approve_gate → approveGatePr (POST /gate-approvals/:pr/approve)
10
+ // The government module only checks the act's shape and calls in through the
11
+ // 'governor.matterAct' contribution point (a module never requires a sibling).
12
+ // Each act returns { done, reason? }: a refusal the route would give (already
13
+ // decided, not open, owner only) is a reason, never a throw.
14
+
15
+ const api = require('../../src/module-api');
16
+ const db = require('./db');
17
+ const doneWhen = require('./done-when');
18
+
19
+ const POINT = 'governor.matterAct';
20
+ const why = (area, ctx) => `A passed ${area} matter (matter ${ctx && ctx.matterId}) decided it.`;
21
+
22
+ // Required lazily: the route files build routers, and only their shared
23
+ // functions are needed here.
24
+ const routes = {
25
+ doneWhen: () => require('./routes/done-when'),
26
+ gate: () => require('./routes/gate-approvals'),
27
+ };
28
+
29
+ const ACTS = Object.freeze({
30
+ review: Object.freeze({
31
+ satisfy_criterion: async (action, ctx) => {
32
+ const c = await doneWhen.getCriterion(action.criterion_id);
33
+ if (!c) return { done: false, reason: 'criterion_not_found' };
34
+ if (c.satisfied === true) return { done: false, reason: 'already_satisfied' };
35
+ await routes.doneWhen().satisfyByOverride({ criterionId: action.criterion_id, signerId: ctx.decidedBy, reason: why('Review', ctx) });
36
+ return { done: true };
37
+ },
38
+ }),
39
+ release: Object.freeze({
40
+ approve_override: async (action, ctx) => {
41
+ try {
42
+ await db.decideOverrideRequest({ requestId: action.request_id, archonId: ctx.decidedBy, decision: 'approve', note: why('Release', ctx) });
43
+ return { done: true };
44
+ } catch (err) {
45
+ if (err && err.code === 'REQUEST_NOT_OPEN') return { done: false, reason: 'request_not_open' };
46
+ if (err && err.code === 'OVERRIDE_OWNER_ONLY') return { done: false, reason: 'override_owner_only' };
47
+ throw err;
48
+ }
49
+ },
50
+ approve_gate: async (action, ctx) => {
51
+ const out = await routes.gate().approveGatePr({ number: action.pr, login: `council, matter ${ctx && ctx.matterId}` });
52
+ return out.ok ? { done: true } : { done: false, reason: out.code };
53
+ },
54
+ }),
55
+ });
56
+
57
+ function contributeLifecycleMatterActs() {
58
+ for (const [area, acts] of Object.entries(ACTS)) {
59
+ for (const [type, act] of Object.entries(acts)) api.contribute(POINT, { area, type, act });
60
+ }
61
+ }
62
+
63
+ module.exports = { ACTS, contributeLifecycleMatterActs };
@@ -25,6 +25,7 @@ const goalEdits = require('../goal-edits.js');
25
25
  const uatDb = require('../criterion-uat-db.js');
26
26
  const { isReservedCriterionSlug } = require('../criterion-ref.js');
27
27
  const { validateOrRespond, parseId, asyncHandler, LIMITS } = api;
28
+ const log = api.logger('lifecycle');
28
29
 
29
30
  // authorizeCriterionCreate — the ORDERED, FAIL-CLOSED decision for a non-Archon
30
31
  // POST /versions/:id/done-when (ADR 0154). PURE + exported so every rejection is
@@ -80,6 +81,24 @@ function authorizeCriterionCreate({ goal, version, actorId, actorRank, actorMemb
80
81
  return { ok: true };
81
82
  }
82
83
 
84
+ // Close a criterion WITHOUT a UAT, on a stated reason: the override row and the
85
+ // flip in ONE transaction (an override row with no flip behind it would claim a
86
+ // close that never happened), then the goal achieves if that was its last
87
+ // criterion (a criterion with no goal completes none). Shared by POST
88
+ // /done-when/:id/satisfy and a passed Review matter (task 1004561,
89
+ // ../matter-acts.js) so the two can never differ.
90
+ async function satisfyByOverride({ criterionId, signerId, reason, taskId = null }) {
91
+ const updated = await api.withTx(async (client) => {
92
+ await uatDb.recordOverride(client, { criterionId, signerId, reason });
93
+ return doneWhen.markCriterion(criterionId, taskId, { client });
94
+ }, { pool: api.pool });
95
+ let goalResult = { achieved: false, goal: null };
96
+ if (updated?.goal_id != null) {
97
+ goalResult = await db.achieveGoalIfComplete({ goalId: updated.goal_id });
98
+ }
99
+ return { updated, goalResult };
100
+ }
101
+
83
102
  module.exports = function buildDoneWhenRouter() {
84
103
  const router = express.Router();
85
104
 
@@ -248,18 +267,7 @@ module.exports = function buildDoneWhenRouter() {
248
267
  });
249
268
  }
250
269
  const taskId = req.body?.satisfied_by_task_id ?? null;
251
- // ONE transaction: an override row with no flip behind it would claim a
252
- // close that never happened, so the two commit together or not at all.
253
- const updated = await api.withTx(async (client) => {
254
- await uatDb.recordOverride(client, { criterionId: id, signerId: req.builder.id, reason });
255
- return doneWhen.markCriterion(id, taskId, { client });
256
- }, { pool: api.pool });
257
- // Auto-achieve: confirming a goal's last criterion flips it open→achieved.
258
- // A criterion with no goal (goal_id NULL) completes no goal.
259
- let goalResult = { achieved: false, goal: null };
260
- if (updated?.goal_id != null) {
261
- goalResult = await db.achieveGoalIfComplete({ goalId: updated.goal_id });
262
- }
270
+ const { updated, goalResult } = await satisfyByOverride({ criterionId: id, signerId: req.builder.id, reason, taskId });
263
271
  // BV1.R08 (task 1003595): when the goal did NOT flip because it still holds
264
272
  // unfinished work, say so. Confirming the last criterion and getting a bare
265
273
  // `goal_achieved: false` back reads as a bug to the agent that just did it —
@@ -466,12 +474,17 @@ module.exports = function buildDoneWhenRouter() {
466
474
 
467
475
  // Auth: flip a criterion back to unsatisfied (rare; for corrections).
468
476
  // rank: archon — un-flip a criterion; same gate as satisfy.
477
+ // An optional `reason` (the Review desk's one-tap chip, task 1004561) is
478
+ // logged beside who undid it; the flip itself is unchanged.
469
479
  router.post('/done-when/:criterionId/unsatisfy', auth.requireBuilder, auth.requirePermission('criterion.unsatisfy'), asyncHandler('POST /done-when/:criterionId/unsatisfy', async (req, res) => {
480
+ if (validateOrRespond(req, res, { reason: { type: 'string', maxLength: 200 } })) return;
470
481
  const id = parseId(req, res, { param: 'criterionId', code: 'bad_criterion_id' });
471
482
  if (id === null) return;
472
483
  const existing = await doneWhen.getCriterion(id);
473
484
  if (!existing) return res.fail('criterion_not_found', 404);
474
485
  const updated = await doneWhen.unmarkCriterion(id);
486
+ const why = typeof req.body?.reason === 'string' && req.body.reason.trim() ? `: ${req.body.reason.trim()}` : '';
487
+ log.info(`[gds] criterion ${id} sign-off UNDONE by builder ${req.builder.id}${why}`);
475
488
  res.json({ criterion: updated });
476
489
  }, { errorCode: 'unsatisfy_failed' }));
477
490
 
@@ -482,3 +495,4 @@ module.exports = function buildDoneWhenRouter() {
482
495
  // tests/criterion_authoring_gate.mjs (DB-free), the authorizeGoalTaskCreate
483
496
  // precedent in routes/goals.js.
484
497
  module.exports.authorizeCriterionCreate = authorizeCriterionCreate;
498
+ module.exports.satisfyByOverride = satisfyByOverride;
@@ -141,6 +141,75 @@ function contributeGateApprovalsToDocket() {
141
141
  });
142
142
  }
143
143
 
144
+ // The approval itself, shared by the route above and a passed Release matter
145
+ // (task 1004561, ../matter-acts.js) so the two can never differ. Returns
146
+ // { ok: true, body } or { ok: false, code, status, message }; throws on an
147
+ // unexpected failure (the caller says it). `login` is who approved, as the
148
+ // status description names it.
149
+ async function approveGatePr({ number, login }) {
150
+ if (!githubPush.isConfigured()) {
151
+ return { ok: false, code: 'publish_unconfigured', status: 503, message: 'No server GitHub credential configured — cannot approve.' };
152
+ }
153
+ // Resolve the live OPEN PR + its current head sha (the approval is sha-pinned,
154
+ // so a later commit's head carries no approval — red-team #2b protection).
155
+ const prs = await githubPush.listOpenPullRequests({});
156
+ const pr = prs.find((p) => p.number === number);
157
+ if (!pr) return { ok: false, code: 'pr_not_open', status: 404, message: `No open PR #${number}.` };
158
+ if (!pr.head_sha) return { ok: false, code: 'no_head_sha', status: 409, message: 'Could not resolve the PR head commit.' };
159
+
160
+ const gate = findCheck(pr, 'gate-review');
161
+ if (gate && isSuccess(gate)) {
162
+ return { ok: true, body: { ok: true, pr: number, already_clear: true, message: 'gate-review is already green — nothing to approve.' } };
163
+ }
164
+
165
+ // 1. Set the trustless owner-approval status on the head sha.
166
+ const set = await githubPush.setGateOwnerApproved({ sha: pr.head_sha, login, targetUrl: pr.url });
167
+ if (!set) {
168
+ // 500, not 502 — Cloudflare masks an origin 502 with its branded page (see the route's catch).
169
+ return { ok: false, code: 'status_set_failed', status: 500, message: 'Could not set the gate-owner-approved status.' };
170
+ }
171
+
172
+ // 2. Re-trigger gate-review (close+reopen → `pull_request: reopened`) so the
173
+ // required check re-evaluates the new approval; re-arm auto-merge.
174
+ // A PAGE TWEAK HELD FOR ITS ARTIST is reopened but NOT re-armed (task
175
+ // 1004323): clearing the gate check is the owner's act on the PR, and
176
+ // approving the page is the artist's (ADR 0341 D7). Asked fail-closed: a
177
+ // read that throws answers held, and an ordinary confirmed task left
178
+ // un-armed still lands through the publish reconciler's sweep.
179
+ const prTask = taskIdFromPr(pr);
180
+ const held = prTask != null && await pageTweakReads.heldForArtistFailClosed(prTask);
181
+ let recheck = { reopened: false, auto_merge: false };
182
+ try {
183
+ recheck = await githubPush.recheckPullRequest(held ? { number, arm: false, heldReason: HELD_NOT_ARMED.reason } : { number });
184
+ } catch (e) {
185
+ // The status IS set; if the re-trigger failed, the next push or a manual
186
+ // re-run still clears it, and the publish reconciler sweep merges once green.
187
+ console.error('[gds] gate-approval recheck failed', e);
188
+ return {
189
+ ok: true,
190
+ body: {
191
+ ok: true, pr: number, head_sha: pr.head_sha, status_set: true, recheck: 'failed',
192
+ message: 'Approval recorded. Re-run of the gate check did not fire — it will clear on the next push or a manual re-run.',
193
+ },
194
+ };
195
+ }
196
+
197
+ return {
198
+ ok: true,
199
+ body: {
200
+ ok: true,
201
+ pr: number,
202
+ head_sha: pr.head_sha,
203
+ status_set: true,
204
+ recheck,
205
+ held_for_artist: held,
206
+ message: held
207
+ ? 'Approval recorded. Re-running the gate check. This is a page tweak waiting for its artist, so it lands only once the artist approves it in the studio.'
208
+ : 'Approval recorded. Re-running the gate check — the PR merges once it goes green.',
209
+ },
210
+ };
211
+ }
212
+
144
213
  module.exports = function buildGateApprovalsRouter() {
145
214
  contributeGateApprovalsToDocket();
146
215
  const router = express.Router();
@@ -189,66 +258,10 @@ module.exports = function buildGateApprovalsRouter() {
189
258
  router.post('/gate-approvals/:pr/approve', auth.requireBuilder, auth.requirePermission('gate_approval.decide'), async (req, res) => {
190
259
  const number = parseId(req, res, { param: 'pr', code: 'bad_pr', positiveInt: true });
191
260
  if (number == null) return;
192
- if (!githubPush.isConfigured()) {
193
- return res.fail('publish_unconfigured', { status: 503, message: 'No server GitHub credential configured — cannot approve.' });
194
- }
195
261
  try {
196
- // Resolve the live OPEN PR + its current head sha (the approval is sha-pinned,
197
- // so a later commit's head carries no approval — red-team #2b protection).
198
- const prs = await githubPush.listOpenPullRequests({});
199
- const pr = prs.find((p) => p.number === number);
200
- if (!pr) return res.fail('pr_not_open', { status: 404, message: `No open PR #${number}.` });
201
- if (!pr.head_sha) return res.fail('no_head_sha', { status: 409, message: 'Could not resolve the PR head commit.' });
202
-
203
- const gate = findCheck(pr, 'gate-review');
204
- if (gate && isSuccess(gate)) {
205
- return res.json({ ok: true, pr: number, already_clear: true, message: 'gate-review is already green — nothing to approve.' });
206
- }
207
-
208
- // 1. Set the trustless owner-approval status on the head sha.
209
- const set = await githubPush.setGateOwnerApproved({
210
- sha: pr.head_sha,
211
- login: req.builder.github_login,
212
- targetUrl: pr.url,
213
- });
214
- if (!set) {
215
- // 500, not 502 — Cloudflare masks an origin 502 with its branded page (see catch).
216
- return res.fail('status_set_failed', { status: 500, message: 'Could not set the gate-owner-approved status.' });
217
- }
218
-
219
- // 2. Re-trigger gate-review (close+reopen → `pull_request: reopened`) so the
220
- // required check re-evaluates the new approval; re-arm auto-merge.
221
- // A PAGE TWEAK HELD FOR ITS ARTIST is reopened but NOT re-armed (task
222
- // 1004323): clearing the gate check is the owner's act on the PR, and
223
- // approving the page is the artist's (ADR 0341 D7). Asked fail-closed: a
224
- // read that throws answers held, and an ordinary confirmed task left
225
- // un-armed still lands through the publish reconciler's sweep.
226
- const prTask = taskIdFromPr(pr);
227
- const held = prTask != null && await pageTweakReads.heldForArtistFailClosed(prTask);
228
- let recheck = { reopened: false, auto_merge: false };
229
- try {
230
- recheck = await githubPush.recheckPullRequest(held ? { number, arm: false, heldReason: HELD_NOT_ARMED.reason } : { number });
231
- } catch (e) {
232
- // The status IS set; if the re-trigger failed, the next push or a manual
233
- // re-run still clears it, and the publish reconciler sweep merges once green.
234
- console.error('[gds] gate-approval recheck failed', e);
235
- return res.json({
236
- ok: true, pr: number, head_sha: pr.head_sha, status_set: true, recheck: 'failed',
237
- message: 'Approval recorded. Re-run of the gate check did not fire — it will clear on the next push or a manual re-run.',
238
- });
239
- }
240
-
241
- res.json({
242
- ok: true,
243
- pr: number,
244
- head_sha: pr.head_sha,
245
- status_set: true,
246
- recheck,
247
- held_for_artist: held,
248
- message: held
249
- ? 'Approval recorded. Re-running the gate check. This is a page tweak waiting for its artist, so it lands only once the artist approves it in the studio.'
250
- : 'Approval recorded. Re-running the gate check — the PR merges once it goes green.',
251
- });
262
+ const out = await approveGatePr({ number, login: req.builder.github_login });
263
+ if (!out.ok) return res.fail(out.code, { status: out.status, message: out.message });
264
+ res.json(out.body);
252
265
  } catch (err) {
253
266
  console.error('[gds] POST /gate-approvals/:pr/approve', err);
254
267
  // 500, not 502: Cloudflare replaces an origin 502/504 with its own branded
@@ -261,3 +274,5 @@ module.exports = function buildGateApprovalsRouter() {
261
274
 
262
275
  return router;
263
276
  };
277
+
278
+ module.exports.approveGatePr = approveGatePr;
@@ -139,6 +139,8 @@ function registerSeams() {
139
139
  module.exports = function buildLifecycleRouter() {
140
140
  registerSeams();
141
141
  governorDocket.contributeLifecycleToDocket();
142
+ // what a passed Review or Release matter carries out (task 1004561)
143
+ require('../matter-acts').contributeLifecycleMatterActs();
142
144
  // No HTTP surface — the lifecycle capability is a pure kernel port. The empty
143
145
  // router is only the loader's contributes.routes mount vehicle; returning a
144
146
  // valid express.Router() keeps mountModuleRoutes' `router.use(sub)` happy
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.35",
3
+ "version": "1.21.37",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.35",
9
+ "version": "1.21.37",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.35",
3
+ "version": "1.21.37",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -8970,5 +8970,21 @@
8970
8970
  "id": "1004559",
8971
8971
  "text": "Governing now happens inside the buildings. On a floor's desk you can set a builder's tier, deactivate them, invite someone by GitHub name, give or take back a custom rank; confirm, dispute or mark fixed a security report and"
8972
8972
  }
8973
+ ],
8974
+ "1.21.36": [
8975
+ {
8976
+ "id": "1004561",
8977
+ "text": "Three new governing actions, all in place inside the buildings. In Treasury you can now set each builder's monthly spending limit with one tap. In Review you can undo a sign-off with a one-tap reason. And when a council decide"
8978
+ },
8979
+ {
8980
+ "id": "1004560",
8981
+ "text": "The Planning building now has three working desks on its first floors. Priorities lists the open goals in order with a square for each promise; arrows move a goal up or down, and a save bar (with undo) sets the new order, or,"
8982
+ }
8983
+ ],
8984
+ "1.21.37": [
8985
+ {
8986
+ "id": "1004495",
8987
+ "text": "The hall now looks different in each craft mode. In Artistry, Ideating and Governing the mode button, the current sidebar tab and the top edge of the page take the mode's own colour (moonlight or night ink, gold, city blue), t"
8988
+ }
8973
8989
  ]
8974
8990
  }
@@ -194,6 +194,8 @@ const EXPECTED_RANKS = {
194
194
  // builder authority — rank + lifecycle status mutation
195
195
  'PATCH /builders/:id/rank': 'perm:builder.rank.set',
196
196
  'PATCH /builders/:id/status': 'perm:builder.status.set',
197
+ // task 1004561: the Treasury desk sets a builder's monthly limit through it
198
+ 'PATCH /builders/:id/budget': 'perm:builder.budget.set',
197
199
  // session revocation (kill another builder's / all sessions)
198
200
  'POST /auth/revoke': 'perm:auth.session.revoke',
199
201
  'POST /auth/revoke-all': 'perm:auth.session.revoke',
@@ -205,8 +205,62 @@ function contributeBuilderDesk() {
205
205
  });
206
206
  }
207
207
 
208
+ // The Treasury desk's budget rows (task 1004561): one row per active builder,
209
+ // their monthly spending limit as the fact, and the limits a viewer holding
210
+ // builder.budget.set (PATCH /builders/:id/budget's own atom) may set in one tap.
211
+ // The chip for the limit a builder already has is left off. Pure; exported.
212
+ const BUDGET_CHIPS = Object.freeze([25, 50, 100, 250]);
213
+ function budgetWords(usd) {
214
+ return usd == null ? 'no limit' : `$${Number(usd).toLocaleString('en-US', { maximumFractionDigits: 2 })} a month`;
215
+ }
216
+ function budgetDeskRows(builders) {
217
+ return (builders || []).filter(Boolean).map((b) => {
218
+ const now = b.monthly_budget_usd == null ? null : Number(b.monthly_budget_usd);
219
+ const options = BUDGET_CHIPS.filter((n) => n !== now).map((n) => ({ key: String(n), label: `$${n} a month` }));
220
+ if (now !== null) options.push({ key: 'none', label: 'no limit' });
221
+ return {
222
+ id: b.id, name: b.display_name || b.github_login || `builder ${b.id}`, fact: budgetWords(now),
223
+ facts: b.github_login ? [`@${b.github_login}`] : [], ref: { type: 'builder_budget', id: b.id },
224
+ acts: [{ key: 'budget', options: options.slice(0, 5) }],
225
+ };
226
+ });
227
+ }
228
+ // The one write both the route and a passed Treasury matter make.
229
+ async function setMonthlyBudget(builderId, usd) {
230
+ return seams.resolveOptional('reward').setBuilderMonthlyBudget(builderId, usd);
231
+ }
232
+ function contributeTreasury() {
233
+ seams.contribute('governor.docket', {
234
+ desk: 'treasury.budgets',
235
+ gate: { permission: 'builder.budget.set' },
236
+ // one read: the active roster and each limit together
237
+ read: async () => {
238
+ const { rows } = await pool.query(
239
+ `SELECT id, github_login, display_name, monthly_budget_usd
240
+ FROM builders
241
+ WHERE status = 'active'
242
+ ORDER BY COALESCE(display_name, github_login)`,
243
+ );
244
+ return budgetDeskRows(rows);
245
+ },
246
+ });
247
+ // What a passed Treasury matter carries out, when the decision table puts
248
+ // Treasury to a sitting: the same write as PATCH /builders/:id/budget.
249
+ seams.contribute('governor.matterAct', {
250
+ area: 'treasury',
251
+ type: 'set_budget',
252
+ act: async (action, ctx) => {
253
+ const updated = await setMonthlyBudget(action.builder_id, action.monthly_usd);
254
+ if (!updated) return { done: false, reason: 'builder_not_found' };
255
+ log.info(`[gds] builder ${action.builder_id} monthly budget set to ${budgetWords(action.monthly_usd)} by a passed Treasury matter (matter ${ctx && ctx.matterId})`);
256
+ return { done: true };
257
+ },
258
+ });
259
+ }
260
+
208
261
  module.exports = function buildBuildersRouter() {
209
262
  contributeBuilderDesk();
263
+ contributeTreasury();
210
264
  const router = express.Router();
211
265
 
212
266
  // rank: archon — sole chokepoint that mutates builders.rank; ADR 0016 §enforcement.
@@ -361,7 +415,7 @@ module.exports = function buildBuildersRouter() {
361
415
  // setBuilderMonthlyBudget carved to modules/economy/ (BV1.R77 / ADR 0093
362
416
  // §1-2): resolve the `reward` kernel port (economy is default-on). The
363
417
  // archon rank gate on this route is unchanged.
364
- const updated = await seams.resolveOptional('reward').setBuilderMonthlyBudget(targetId, budget);
418
+ const updated = await setMonthlyBudget(targetId, budget);
365
419
  if (!updated) return res.fail('builder_not_found', 404);
366
420
  res.json({ builder: updated });
367
421
  } catch (err) {
@@ -747,3 +801,4 @@ module.exports = function buildBuildersRouter() {
747
801
  };
748
802
 
749
803
  module.exports.builderDeskRows = builderDeskRows;
804
+ module.exports.budgetDeskRows = budgetDeskRows;
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.35'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.37'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');