@bongos/core 1.19.534 → 1.19.536

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 (40) hide show
  1. package/.bongos-core.json +74 -39
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +11 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/api/openapi.json +186 -3
  8. package/docs/api-reference.md +10 -2
  9. package/docs/copy-inventory.md +64 -43
  10. package/docs/copy-registry.json +293 -62
  11. package/docs/module-api-changelog.md +4 -0
  12. package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
  13. package/modules/government/catalog.js +1 -0
  14. package/modules/government/migrations/government_010_project_settings_manage.sql +58 -0
  15. package/modules/hall-ui/public/hall-kit.js +93 -7
  16. package/modules/hall-ui/public/project-settings.css +90 -0
  17. package/modules/hall-ui/public/project-settings.html +68 -0
  18. package/modules/hall-ui/public/project-settings.js +249 -0
  19. package/modules/hall-ui/public/roadmap-timeline.js +105 -0
  20. package/modules/hall-ui/public/roadmap.html +7 -1
  21. package/modules/hall-ui/public/roadmap.js +16 -0
  22. package/modules/hall-ui/public/shell.js +3 -0
  23. package/modules/hall-ui/public/style.css +4 -2
  24. package/modules/hall-ui/public/watch.html +4 -0
  25. package/modules/hall-ui/public/watch.js +116 -0
  26. package/modules/lifecycle/goal-category.js +82 -0
  27. package/modules/lifecycle/module.json +2 -1
  28. package/modules/lifecycle/routes/goals.js +16 -0
  29. package/modules/lifecycle/routes/project-settings.js +263 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/scripts/hall-preview/refresh-fixtures.js +6 -0
  33. package/src/bongos/serve-internal.js +12 -1
  34. package/src/module-api.js +13 -1
  35. package/tests/hall_kit_charts.mjs +173 -0
  36. package/tests/hall_nav.mjs +3 -1
  37. package/tests/hall_oversight_world.mjs +159 -0
  38. package/tests/hall_page_gate_map.mjs +4 -0
  39. package/tests/hall_reading_world.mjs +153 -0
  40. package/tests/project_settings_page.mjs +337 -0
@@ -134,6 +134,39 @@ async function setGoalCategory({ goalId, categoryId }, deps = {}) {
134
134
  return rows[0] ?? null;
135
135
  }
136
136
 
137
+ // Update one category's SHAPE — its label, its place in the order, or whether it
138
+ // is still offered. Task 1003280's settings page is the surface that needed this:
139
+ // the table has carried `sort_order` and `active` since core_226, but nothing could
140
+ // write them, so a project could only ever ADD to its vocabulary.
141
+ //
142
+ // PATCH SEMANTICS, NOT PUT: undefined means "leave it", so a reorder never has to
143
+ // resend the label and cannot blank it by omission. COALESCE does that in SQL,
144
+ // which is why every parameter is cast — a bare NULL parameter has no type and
145
+ // Postgres refuses to infer one through COALESCE.
146
+ //
147
+ // THE SLUG IS NOT PATCHABLE, deliberately. It is the stable key a goal's prose and
148
+ // the /goal-create prompt refer to; renaming it would silently orphan those. The
149
+ // LABEL is the human-facing string and is freely editable — that split is the whole
150
+ // reason the table carries both.
151
+ //
152
+ // DEACTIVATE, NEVER DELETE: `active=false` drops a category from the offered set
153
+ // while every goal already carrying it keeps a resolvable label. A DELETE would
154
+ // either cascade (rewriting history) or fail on the FK (a dead end for the owner).
155
+ async function updateCategory({ id, label, sortOrder, active }, deps = {}) {
156
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
157
+ const { rows } = await activePool.query(
158
+ `UPDATE work_categories
159
+ SET label = COALESCE($2::text, label),
160
+ sort_order = COALESCE($3::integer, sort_order),
161
+ active = COALESCE($4::boolean, active),
162
+ updated_at = now()
163
+ WHERE id = $1
164
+ RETURNING ${CATEGORY_COLS}`,
165
+ [id, label ?? null, sortOrder ?? null, active ?? null]
166
+ );
167
+ return rows[0] ?? null;
168
+ }
169
+
137
170
  // -------------------------------------------------------------------------
138
171
  // The advisory (task 1003277)
139
172
  // -------------------------------------------------------------------------
@@ -246,6 +279,53 @@ function categoryCreateHandler({ create = createCategory, validateOrRespond, LIM
246
279
  // before/after text. A category is a label, not prose — re-labelling is cheap and
247
280
  // reversible, and folding it into the prose handler would either subject a label
248
281
  // to the prose rules or loosen the prose rules to suit a label.
282
+ // PATCH /work-categories/:id — rename, reorder or retire one category.
283
+ //
284
+ // EVERY FIELD OPTIONAL, and "no field given" is a 400 rather than a silent no-op: a
285
+ // form that submitted nothing has a bug, and answering 200 would hide it behind a row
286
+ // that looks saved.
287
+ //
288
+ // `active: false` is the retire verb. There is no delete: see updateCategory's header.
289
+ function categoryUpdateHandler({ update = updateCategory, validateOrRespond, LIMITS, parseId, log } = {}) {
290
+ return async function patchWorkCategory(req, res) {
291
+ const id = parseId(req, res, { code: 'bad_category_id', positiveInt: true });
292
+ if (id === null) return;
293
+ if (validateOrRespond(req, res, {
294
+ label: { type: 'string', minLength: 1, maxLength: LIMITS.TITLE },
295
+ sort_order: { type: 'number' },
296
+ active: { type: 'boolean' },
297
+ })) return;
298
+
299
+ const body = req.body || {};
300
+ const label = body.label === undefined ? undefined : String(body.label).trim();
301
+ if (label !== undefined && !label) return res.fail('label_required', 400);
302
+ // Whole numbers only. A fractional sort_order would round in the column and land
303
+ // somewhere the caller did not ask for, which is worse than refusing.
304
+ const sortOrder = body.sort_order === undefined ? undefined : Number(body.sort_order);
305
+ if (sortOrder !== undefined && !Number.isInteger(sortOrder)) {
306
+ return res.fail('bad_sort_order', 400, { message: 'sort_order must be a whole number' });
307
+ }
308
+ const active = body.active === undefined ? undefined : Boolean(body.active);
309
+ if (label === undefined && sortOrder === undefined && active === undefined) {
310
+ return res.fail('nothing_to_update', 400, {
311
+ message: 'Send at least one of label, sort_order or active.',
312
+ });
313
+ }
314
+
315
+ try {
316
+ // `UPDATE … RETURNING` already yields null for a missing row, so a separate
317
+ // pre-read to produce the 404 was a second PK query for nothing (task 1003280
318
+ // grade). A null return here IS "no such category" → 404, not a 500.
319
+ const row = await update({ id, label, sortOrder, active });
320
+ if (!row) return res.fail('category_not_found', 404);
321
+ return res.json({ ok: true, category: row });
322
+ } catch (err) {
323
+ log?.error({ err: err && err.message }, 'PATCH /work-categories/:id failed');
324
+ return res.fail('category_update_failed', { status: 500, message: 'internal error' });
325
+ }
326
+ };
327
+ }
328
+
249
329
  function goalCategoryHandler({ db, authorizeGoalManagement, validateOrRespond, parseId, set = setGoalCategory, read = getCategory, index = categoryIndex, log } = {}) {
250
330
  return async function patchGoalCategory(req, res) {
251
331
  const id = parseId(req, res, { code: 'bad_goal_id' });
@@ -292,10 +372,12 @@ module.exports = {
292
372
  getCategory,
293
373
  adviseCategory,
294
374
  createCategory,
375
+ updateCategory,
295
376
  setGoalCategory,
296
377
  decorateGoalCategory,
297
378
  categoryIndex,
298
379
  categoriesListHandler,
299
380
  categoryCreateHandler,
381
+ categoryUpdateHandler,
300
382
  goalCategoryHandler,
301
383
  };
@@ -24,7 +24,8 @@
24
24
  "mingle",
25
25
  "visuals",
26
26
  "kickoff-seed",
27
- "rot"
27
+ "rot",
28
+ "project-settings"
28
29
  ],
29
30
  "pollers": [
30
31
  "publish-reconciler"
@@ -364,6 +364,22 @@ module.exports = function buildGoalsRouter() {
364
364
  // so two people prompted at once converge instead of one being stranded.
365
365
  router.post('/work-categories', auth.requireBuilder, auth.requirePermission('goal.create'),
366
366
  goalCategory.categoryCreateHandler({ validateOrRespond, LIMITS, log }));
367
+ // PATCH /work-categories/:id — body { label?, sort_order?, active? }: rename a
368
+ // category, move it in the order, or stop offering it. The set has been list+add
369
+ // only since core_226 even though the table carries both columns, so a project
370
+ // could grow its vocabulary and never tidy it (task 1003280).
371
+ //
372
+ // rank: project.settings.manage (metic+, government_010) — the SHAPE of the set,
373
+ // one tier up from POST's goal.create, because reordering and deactivating affect
374
+ // what every future author is offered rather than adding one label to the tail.
375
+ //
376
+ // The slug is deliberately NOT patchable: it is the stable key goal prose and the
377
+ // /goal-create prompt refer to. Deactivating never deletes — goals already carrying
378
+ // a retired category keep a resolvable label.
379
+ router.patch('/work-categories/:id', auth.requireBuilder,
380
+ auth.requirePermission('project.settings.manage'),
381
+ goalCategory.categoryUpdateHandler({ validateOrRespond, LIMITS, parseId, log }));
382
+
367
383
  // PATCH /goals/:id/category — body { category_id } (null clears it). Separate from
368
384
  // PATCH /goals/:id because that route is the PROSE edit with its write-once posture
369
385
  // and before/after audit delta; a label is neither.
@@ -0,0 +1,263 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/routes/project-settings.js — the five fundamentals of a project,
4
+ // in one read, each carrying WHERE its value came from (task 1003280, goal 1000072,
5
+ // criterion legibility-project-settings).
6
+ //
7
+ // WHY THIS ROUTE EXISTS. Goal 1000072's thesis is that this project is legible to
8
+ // Claude and illegible to a human: the system knows everything and exposes almost
9
+ // none of it in a shape a person can hold. The five values here — the rot timer, the
10
+ // category set, how credits are calculated, whether equity is real, and what share of
11
+ // upmarket revenue is paid on credits — were each answerable only by reading source.
12
+ // This route is the TRANSLATION LAYER, not a new capability: it invents no value and
13
+ // stores nothing of its own.
14
+ //
15
+ // THE ONE RULE THAT SHAPES EVERY ROW: a value is reported WITH ITS PROVENANCE. A
16
+ // number with no provenance is what let a 6h and a 24h staleness answer coexist
17
+ // unnoticed for months (ADR 0226), which is the exact failure this goal is named for.
18
+ // So every row says whether it is a live knob or committed config, and names the file
19
+ // (and ADR) it resolves from. A row the reader cannot type into must LOOK unwritable
20
+ // and say why.
21
+ //
22
+ // AND THE ONE THAT KEEPS IT HONEST: a fundamental with no knob behind it is reported
23
+ // as ABSENT, never as a plausible zero. `upmarket_percent` is that row today — the
24
+ // task asked for it, and no such knob exists anywhere in this codebase. Rendering it
25
+ // as "0%" would be a fabricated fact about the project's economics; saying "there is
26
+ // no such setting yet, and here is where it would live" is the legible answer.
27
+ //
28
+ // MODULE BOUNDARY: reaches core only through the published doorway
29
+ // `../../../src/module-api`; `../goal-category` is an intra-module sibling.
30
+
31
+ const express = require('express');
32
+ const api = require('../../../src/module-api');
33
+ const goalCategory = require('../goal-category');
34
+
35
+ const log = api.logger('lifecycle');
36
+
37
+ // --- the three rows that are READ, never written -----------------------------
38
+ //
39
+ // Each names the committed artefact it resolves from, because "you cannot edit this"
40
+ // is only half an answer; the other half is "and here is the file that decides it".
41
+
42
+ // The credit formula IN FORCE, asked of the economy module rather than restated here.
43
+ // ADR 0146 put the reward policy inside economy precisely so display surfaces ask
44
+ // instead of re-deriving it; a second copy of `round(cost × 1.20)` in a settings page
45
+ // is the drift this goal exists to end. When economy is off there is no formula to
46
+ // report, and saying so beats printing one that nothing applies.
47
+ function creditRow() {
48
+ const reward = api.resolveOptional('reward');
49
+ if (!reward || typeof reward.rewardConfig !== 'function') {
50
+ return {
51
+ key: 'credit_calculation',
52
+ title: 'Credit calculation',
53
+ editable: false,
54
+ state: 'unavailable',
55
+ sentence: 'The economy module is not enabled on this instance, so no credit formula is in force.',
56
+ source: 'module `economy` (absent)',
57
+ reads: { file: 'modules/economy/credits.js', adr: 'ADR 0054' },
58
+ };
59
+ }
60
+ let policy;
61
+ try {
62
+ policy = reward.rewardConfig();
63
+ } catch (err) {
64
+ log.error(`project-settings: rewardConfig() failed: ${err && err.message}`);
65
+ return {
66
+ key: 'credit_calculation',
67
+ title: 'Credit calculation',
68
+ editable: false,
69
+ state: 'unavailable',
70
+ sentence: 'The credit formula could not be read just now.',
71
+ source: 'module `economy`',
72
+ reads: { file: 'modules/economy/credits.js', adr: 'ADR 0054' },
73
+ };
74
+ }
75
+ const mult = (1 + Number(policy.marginPct) / 100).toFixed(2);
76
+ const peg = Number(policy.usdPeg);
77
+ const pegNote = peg !== 1 ? ` × ${peg} peg` : '';
78
+ // The per-task estimate is ADVERTISED but never paid when the mode is cost-plus-only
79
+ // (ADR 0146). A page that showed the estimate without that sentence would be telling
80
+ // a builder they will earn a number this instance has decided not to pay.
81
+ const estimate = policy.perTaskCredit
82
+ ? 'A task’s own credit estimate is also paid, scaled by its kind.'
83
+ : 'A task’s credit estimate is an advertised guide only — this instance pays cost-plus and nothing else.';
84
+ return {
85
+ key: 'credit_calculation',
86
+ title: 'Credit calculation',
87
+ editable: false,
88
+ state: 'live',
89
+ value: { marginPct: policy.marginPct, usdPeg: policy.usdPeg, mode: policy.mode, perTaskCredit: policy.perTaskCredit },
90
+ sentence: `A shipped session pays round(true cost in USD × ${mult}${pegNote}). ${estimate}`,
91
+ source: 'branding pack → `reward`',
92
+ reads: { file: 'config/branding.json → reward', adr: 'ADR 0054, ADR 0146' },
93
+ };
94
+ }
95
+
96
+ // Equity. THE OWNER DECIDED THIS (2026-08-25) and the decision is that it is NOT REAL:
97
+ // credits ARE the ownership stream today, and no legal equity is issued. The page says
98
+ // that plainly rather than implying a cap table exists — an owner reading their own
99
+ // settings page must not come away believing shares are being issued. If issuance ever
100
+ // becomes real it gets its own decision record; the Board Room spec already parks
101
+ // Howey / Subchapter T explicitly out of scope.
102
+ function equityRow() {
103
+ return {
104
+ key: 'equity_calculation',
105
+ title: 'Equity calculation',
106
+ editable: false,
107
+ state: 'not-yet-real',
108
+ sentence: 'No legal equity is issued. Credits are the ownership stream this project actually runs — '
109
+ + 'the owner’s framing in ADR 0054 is that a credit IS a unit of ownership. There is no cap table, '
110
+ + 'no share class and no issuance behind this row.',
111
+ source: 'owner decision, 2026-08-25',
112
+ reads: { file: 'docs/adr/0054-drachmae-token-reward.md', adr: 'ADR 0054' },
113
+ };
114
+ }
115
+
116
+ // The share of upmarket revenue paid out on credits. NO SUCH KNOB EXISTS — searched
117
+ // across code, config, migrations and docs on this task and found only prose in an
118
+ // unrelated competitive-analysis file. So this row reports ABSENCE and names where the
119
+ // setting would live if it became real, which is the actionable fact. A rendered "0%"
120
+ // would be a fabricated economic claim; an omitted row would leave the owner to
121
+ // rediscover the gap by grep.
122
+ function upmarketRow() {
123
+ return {
124
+ key: 'upmarket_percent',
125
+ title: 'Percent of upmarket revenue paid on credits',
126
+ editable: false,
127
+ state: 'absent',
128
+ sentence: 'This project has no such setting yet — nothing in the code, config or database '
129
+ + 'defines it, so there is no number to show. It would belong beside the other reward '
130
+ + 'terms in the branding pack’s `reward` block, and paying against it would need its own '
131
+ + 'decision record first.',
132
+ source: 'not configured anywhere',
133
+ reads: { file: 'config/branding.json → reward (where it would live)', adr: null },
134
+ };
135
+ }
136
+
137
+ module.exports = function buildProjectSettingsRouter() {
138
+ const router = express.Router();
139
+
140
+ // GET /project-settings — all five rows in ONE read.
141
+ //
142
+ // rank: project.settings.manage (metic+, government_010), the SAME floor as the page
143
+ // it serves (requireGovernmentPage). The first cut gated this any-builder on the
144
+ // reasoning that it is "the project's own published terms" — but the payload also
145
+ // carries reward-policy internals (marginPct, usdPeg, mode), a raw rejected
146
+ // project_settings value, and internal file/migration paths, and a xenos who cannot
147
+ // even load the page could still curl those. Matching the read floor to the page's
148
+ // (task 1003280 grade, hacker lens) closes that asymmetry; the two WRITES already
149
+ // gate on this same atom, so the whole surface is now one floor.
150
+ //
151
+ // One read, not five: the page draws five rows, so it asks for five rows. Fetching
152
+ // per-row would put the hall's own settings page in the shape goal 1000072's
153
+ // criterion names (hall-ui/CLAUDE.md, "a card asks the server for the rows it draws").
154
+ router.get('/project-settings', api.requireBuilder, api.requirePermission('project.settings.manage'), async (req, res) => {
155
+ try {
156
+ const timer = await api.resolveRotDays();
157
+ // Inactive included: the set's SHAPE is what this page edits, and a deactivated
158
+ // category the owner cannot see is one they cannot bring back.
159
+ const categories = await goalCategory.listCategories({ includeInactive: true });
160
+
161
+ // A settings read is a configuration comparison — a cached one is wrong the
162
+ // moment somebody turns a knob (the rot.js precedent).
163
+ res.set('Cache-Control', 'no-store');
164
+ res.json({
165
+ rows: [
166
+ {
167
+ key: 'rot_timer',
168
+ title: 'Stale timer',
169
+ editable: true,
170
+ state: 'live',
171
+ value: { days: timer.days, min: api.projectSettings.MIN_ROT_DAYS, max: api.projectSettings.MAX_ROT_DAYS },
172
+ sentence: `Work nobody has touched for ${timer.days} day${timer.days === 1 ? '' : 's'} `
173
+ + 'is raised as a needs-attention item. Raising it only ever ASKS a person; nothing is '
174
+ + 'released or reassigned automatically.',
175
+ source: timer.source,
176
+ describe: api.projectSettings.describeRotTimer(timer),
177
+ // Surfaced ONLY when a configured value could not be used. It belongs on the
178
+ // screen of the person who can fix it — the reporting-degrades half of ADR
179
+ // 0226's asymmetric fail posture.
180
+ invalid: timer.invalid,
181
+ reads: { file: 'project_settings[\'attention.rot_days\'] (migration core_231)', adr: 'ADR 0226' },
182
+ // THE ROW THAT PREVENTS THIS PAGE FROM LYING. There are two timers, and the
183
+ // OTHER one — the claim sweep — enforces rather than reports, is measured in
184
+ // hours, and is NOT editable here. Naming it inline is what stops a reader
185
+ // concluding they have just reconfigured the sweep that auto-releases claims.
186
+ sibling: {
187
+ title: 'The claim sweep is a different timer',
188
+ sentence: 'A CLAIM that goes silent is handled separately, in hours, and the sweep '
189
+ + 'RELEASES it without asking. That timer is committed configuration, not a knob on '
190
+ + 'this page.',
191
+ reads: { file: 'src/stale-timer.js', adr: 'ADR 0226' },
192
+ },
193
+ },
194
+ {
195
+ key: 'work_categories',
196
+ title: 'Category set',
197
+ editable: true,
198
+ state: 'live',
199
+ value: { categories, active: categories.filter((c) => c.active).length, total: categories.length },
200
+ sentence: 'The human categories a goal can carry. They orient a reader and steer Claude — '
201
+ + 'they gate nothing, route nothing and authorise nothing.',
202
+ source: 'work_categories table',
203
+ reads: { file: 'work_categories (migration core_226)', adr: 'ADR 0192' },
204
+ },
205
+ creditRow(),
206
+ equityRow(),
207
+ upmarketRow(),
208
+ ],
209
+ });
210
+ } catch (err) {
211
+ log.error(`GET /project-settings: ${err && err.message}`);
212
+ res.fail('project_settings_read_failed', { status: 500, message: 'internal error' });
213
+ }
214
+ });
215
+
216
+ // PATCH /project-settings/rot-days — body { days }. The one number this page writes.
217
+ //
218
+ // rank: project.settings.manage (metic+, government_010).
219
+ //
220
+ // Validated HERE against the resolver's own parser rather than a re-typed range, so
221
+ // the page and the reader of the value can never disagree about what is acceptable —
222
+ // the second copy of "1..365" would be the drift this file exists to prevent.
223
+ // rank: project.settings.manage (metic+, granted by government_010).
224
+ //
225
+ // THE ATOM IS A STRING LITERAL, not a named constant, and that is load-bearing: the
226
+ // route-rank auditor (src/bongos/route-rank-check.js) reads this file statically and
227
+ // pulls the atom out of `requirePermission(...)` with a QUOTED-STRING match. A
228
+ // `requirePermission(SETTINGS_ATOM)` therefore resolves to an EMPTY gate — rank
229
+ // 'unknown' — which is a hard ADR 0016 build failure, and the generated OpenAPI would
230
+ // have carried no `x-permissions` either. This route was written with a constant
231
+ // first and fitness.js caught it; that is why every gated route in the tree spells
232
+ // its atom inline. (Wrapping the middleware list across lines is fine — the sibling
233
+ // PATCH /work-categories/:id does exactly that and still classifies metic+archon.)
234
+ router.patch('/project-settings/rot-days', api.requireBuilder, api.requirePermission('project.settings.manage'), async (req, res) => {
235
+ // STRICT schema first (ADR 0118): it declares the only field this handler reads,
236
+ // so an unknown key is refused through the ADR 0116 envelope instead of being
237
+ // silently dropped. It settles the TYPE; parseDays below settles the RANGE, using
238
+ // the resolver's own parser so the form and the reader cannot disagree.
239
+ if (api.validateOrRespond(req, res, { days: { required: true, type: 'number' } })) return;
240
+ const parsed = api.projectSettings.parseDays(req.body.days);
241
+ if (parsed.days == null) {
242
+ // parsed.reason already carries the bound ("out of range (1..365)"), so this
243
+ // does not re-type it — the drift the page's own rule forbids (task 1003280 grade).
244
+ return res.fail('bad_rot_days', {
245
+ status: 400,
246
+ message: `days: ${parsed.reason}`,
247
+ });
248
+ }
249
+ try {
250
+ await api.projectSettings.set(api.projectSettings.KEYS.ROT_DAYS, parsed.days, req.builder.id);
251
+ // Re-RESOLVE rather than echoing what was written: the receipt must describe the
252
+ // value the rest of the system will now read, through the same precedence every
253
+ // other caller goes through.
254
+ const timer = await api.resolveRotDays();
255
+ res.json({ ok: true, rot_days: timer.days, source: timer.source, describe: api.projectSettings.describeRotTimer(timer) });
256
+ } catch (err) {
257
+ log.error(`PATCH /project-settings/rot-days: ${err && err.message}`);
258
+ res.fail('project_settings_write_failed', { status: 500, message: 'internal error' });
259
+ }
260
+ });
261
+
262
+ return router;
263
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.534",
3
+ "version": "1.19.536",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.534",
9
+ "version": "1.19.536",
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.19.534",
3
+ "version": "1.19.536",
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",
@@ -63,6 +63,12 @@ const SNAPSHOTS = [
63
63
  ['public/progress', 'public__progress'],
64
64
  ['public/recent-shipped', 'public__recent-shipped'],
65
65
  ['public/repo-info', 'public__repo-info'],
66
+ // The roadmap's OTHER read (task 1003447). It was missing, so /roadmap has
67
+ // always 404'd here and fallen through its `.catch(() => [])` — which meant
68
+ // no track chip and no shipped date in preview, and (once the deck learned to
69
+ // draw them) no timeline. `versions` above is the AUTHENTICATED list; this is
70
+ // the public one the page actually calls, and the two are not interchangeable.
71
+ ['public/versions', 'public__versions'],
66
72
  ];
67
73
 
68
74
  (async () => {
@@ -829,6 +829,17 @@ function mountInternalSurfaces(app) {
829
829
  // data never leaked (the route gate held), but the page was the one item in the
830
830
  // group without a wall. Gated here so the group is uniform (task 1002710).
831
831
  const SESSIONS_PAGE_RE = /^\/builders\/sessions(?:\.html)?\/?$/;
832
+ // /project-settings — the five fundamentals of the project (task 1003280, goal
833
+ // 1000072). Government tier, beside /watch and /sessions: it is a STEERING surface,
834
+ // and ADR 0157 put that floor at metic. Both writes behind it gate independently on
835
+ // `project.settings.manage` (defence in depth unchanged), so this gate decides only
836
+ // who receives the shell.
837
+ //
838
+ // NB the assignment must stay `const NAME_RE = /…/;` with nothing between the `=`
839
+ // and the literal — tests/hall_page_gate_map.mjs rebuilds the URL→gate map by
840
+ // parsing this file statically, and a comment in that gap drops the regex out of
841
+ // the map. Prose goes above the const, as here.
842
+ const PROJECT_SETTINGS_PAGE_RE = /^\/builders\/project-settings(?:\.html)?\/?$/;
832
843
  // The /government ranks-and-permissions tab (BV1.R113) sits one floor higher:
833
844
  // the `government.manage` atom itself (archon), the same key its data routes
834
845
  // check — requireGovernmentManagePage, not the metic-floored gate above.
@@ -861,7 +872,7 @@ function mountInternalSurfaces(app) {
861
872
  if (!req.path.startsWith('/builders')) return next();
862
873
  const norm = path.posix.normalize(req.path);
863
874
  if (WATCH_PAGE_RE.test(norm) || HARBOR_PAGE_RE.test(norm) || GATE_PAGE_RE.test(norm)
864
- || SESSIONS_PAGE_RE.test(norm)) {
875
+ || SESSIONS_PAGE_RE.test(norm) || PROJECT_SETTINGS_PAGE_RE.test(norm)) {
865
876
  return gdsAuth.requireGovernmentPage(req, res, next);
866
877
  }
867
878
  // R16 (goal 1000069) widened the government shell from the manage atom
package/src/module-api.js CHANGED
@@ -28,6 +28,9 @@ const {
28
28
  getSetting: getProjectSetting,
29
29
  setSetting: setProjectSetting,
30
30
  describeRotTimer,
31
+ parseDays: parseRotDays,
32
+ MIN_ROT_DAYS,
33
+ MAX_ROT_DAYS,
31
34
  } = require('./bongos/project-settings');
32
35
  const seams = require('./module-seams');
33
36
  const { buildInfo } = require('./build-info');
@@ -46,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
46
49
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
47
50
  // the entry to that file. Look for a version's history there, not here.
48
51
  // ---------------------------------------------------------------------------
49
- const CORE_VERSION = '1.19.534'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
52
+ const CORE_VERSION = '1.19.536'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
50
53
 
51
54
  // A namespaced logger so a module's log lines are attributable + consistent.
52
55
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -288,6 +291,15 @@ module.exports = {
288
291
  get: getProjectSetting,
289
292
  set: setProjectSetting,
290
293
  describeRotTimer,
294
+ // The RANGE CHECK, shared rather than re-typed. The settings page validates a
295
+ // submitted timer with the very parser the resolver reads it back through, so the
296
+ // form and the reader can never disagree about what 1..365 means (task 1003280).
297
+ parseDays: parseRotDays,
298
+ // The bounds themselves, so a surface that must SHOW them (the settings page's rot
299
+ // row) reads the single source instead of re-typing 1/365 — the very drift the
300
+ // page's own anti-drift rule forbids, caught one layer up on task 1003280's grade.
301
+ MIN_ROT_DAYS,
302
+ MAX_ROT_DAYS,
291
303
  },
292
304
  readConfigFileSync: instanceConfig.readConfigFileSync,
293
305
  configHome: instanceConfig.configHome,