@bongos/core 1.19.1063 → 1.19.1065

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 (65) hide show
  1. package/.bongos-core.json +136 -56
  2. package/.claude/skills/grade-recover/SKILL.md +1 -0
  3. package/.claude/skills/tweak/SKILL.md +70 -0
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +12 -0
  6. package/clients/bongos-client/index.cjs +12 -0
  7. package/clients/bongos-client/index.d.ts +21 -1
  8. package/clients/bongos-client/index.mjs +12 -0
  9. package/docs/api/openapi.json +492 -4
  10. package/docs/api-reference.md +9 -3
  11. package/docs/copy-inventory.md +60 -41
  12. package/docs/copy-registry.json +228 -47
  13. package/docs/file-map.md +1 -0
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +11 -2
  16. package/docs/page-readings.json +45 -22
  17. package/modules/copy-desk/page-decisions.js +71 -0
  18. package/modules/copy-desk/page-status.js +96 -0
  19. package/modules/copy-desk/pages.js +30 -0
  20. package/modules/copy-desk/routes/copy-desk.js +153 -0
  21. package/modules/copy-desk/tests/copy_no_cms.mjs +40 -4
  22. package/modules/hall-ui/public/approval-queue-lib.js +156 -0
  23. package/modules/hall-ui/public/approval-queue.css +286 -0
  24. package/modules/hall-ui/public/approval-queue.js +338 -0
  25. package/modules/hall-ui/public/studio.css +33 -0
  26. package/modules/hall-ui/public/studio.html +18 -1
  27. package/modules/hall-ui/public/studio.js +30 -0
  28. package/modules/hall-ui/public/studio.states.json +198 -0
  29. package/modules/hall-ui/records/approval-queue.md +32 -0
  30. package/modules/lifecycle/db-grade.js +96 -58
  31. package/modules/lifecycle/github-pr-close.js +41 -0
  32. package/modules/lifecycle/github-push.js +5 -2
  33. package/modules/lifecycle/lifecycle.js +12 -0
  34. package/modules/lifecycle/migrations/lifecycle_013_task_visual_slots.sql +52 -0
  35. package/modules/lifecycle/page-tweak-approve.js +242 -0
  36. package/modules/lifecycle/page-tweak-hold.js +74 -0
  37. package/modules/lifecycle/page-tweak-reads.js +33 -1
  38. package/modules/lifecycle/routes/gate-approvals.js +17 -2
  39. package/modules/lifecycle/routes/tasks.js +4 -2
  40. package/modules/lifecycle/routes/visuals.js +82 -0
  41. package/modules/lifecycle/task-visual-db.js +40 -1
  42. package/modules/lifecycle/task-visuals.js +24 -0
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +12 -0
  46. package/scripts/gds/copy-apply.js +277 -1
  47. package/scripts/gds/page-reader.js +2 -0
  48. package/scripts/gds/ship-finish.js +27 -2
  49. package/scripts/gds/ship-flow.js +17 -1
  50. package/scripts/gds/ship-land.js +30 -7
  51. package/scripts/gds/ship-merge.js +43 -15
  52. package/scripts/gds/strand-watch.js +41 -1
  53. package/scripts/gds/task.js +23 -5
  54. package/scripts/gds/tweak-renders.js +200 -0
  55. package/scripts/hall-preview/server.js +18 -0
  56. package/src/module-api.js +1 -1
  57. package/tests/copy_desk_page_approve.mjs +307 -0
  58. package/tests/fixtures/page-ledger.mjs +41 -1
  59. package/tests/gate_approvals.mjs +93 -0
  60. package/tests/hall_approval_queue.mjs +327 -0
  61. package/tests/hall_audit.mjs +4 -0
  62. package/tests/page_tweak_hold.mjs +265 -0
  63. package/tests/publish_status_branch.mjs +23 -0
  64. package/tests/task_visual_slots.mjs +161 -0
  65. package/tests/tweak_batch_apply.mjs +265 -0
@@ -0,0 +1,74 @@
1
+ // modules/lifecycle/page-tweak-hold.js — the ONE definition of "a page tweak
2
+ // waiting for its artist" (task 1004322 / BV2.TW11, ADR 0341 D7).
3
+ //
4
+ // THE HOLD. A page tweak transcribes words an artist wrote, so its artist must
5
+ // see the applied page before it ships (spec decision 4). When a page tweak's
6
+ // grade PASSES, applyGrade records the grade but does not promote `completed`
7
+ // to `confirmed`: the task waits at `completed` until the artist approves it
8
+ // (TW12's approve route does the promotion the grade withheld) or sends it back.
9
+ //
10
+ // WHY ONE FILE. Five readers must agree on this one fact, and a second copy of
11
+ // it is how they would drift: applyGrade (do not promote), the publish-branch
12
+ // route (publish, but never arm auto-merge), the publish-status route (never
13
+ // nudge the merge), ship.js (publish the branch and say the page is waiting)
14
+ // and strand-watch / grade-recover (a held pass is not a failed grade). Every
15
+ // one of them asks the questions below.
16
+ //
17
+ // PURE: no require, no fs, no DB. The scripts under scripts/gds/ require it
18
+ // directly, so it must load without the module doorway.
19
+ //
20
+ // Every other task is untouched by construction: each predicate is false unless
21
+ // the task's source is exactly 'page-tweak'.
22
+
23
+ 'use strict';
24
+
25
+ // The task source the page claim writes (modules/copy-desk/pages.js SOURCE).
26
+ // Restated, not required: copy-desk is another module, and this file requires
27
+ // nothing so that the CLI can load it.
28
+ const PAGE_TWEAK_SOURCE = 'page-tweak';
29
+
30
+ // The derived round state this hold produces (ADR 0341 D7's table).
31
+ const HOLD_STATE = 'waiting_for_artist';
32
+
33
+ function isPageTweak(task) {
34
+ return !!task && task.source === PAGE_TWEAK_SOURCE;
35
+ }
36
+
37
+ // Is this task held for its artist? `gradePassed` is the task's grade row's
38
+ // `passed` (task_grades keeps one row per task, the latest). Only a strict
39
+ // `true` holds: a missing grade, or a failed one, is the ordinary `completed`
40
+ // that /grade-recover handles.
41
+ function isHeldForArtist(task, gradePassed) {
42
+ return isPageTweak(task) && task.status === 'completed' && gradePassed === true;
43
+ }
44
+
45
+ // What ship.js prints when it parks a held pass. One place, so the ship, the
46
+ // re-grade and the resume all say the same thing.
47
+ function heldLines(taskId, { prUrl = null } = {}) {
48
+ return [
49
+ '',
50
+ `Task #${taskId} passed its grade and is WAITING FOR THE ARTIST (ADR 0341 D7).`,
51
+ ' This is a page tweak: the artist approves the applied page before it ships.',
52
+ prUrl
53
+ ? ` The branch and its PR are published (${prUrl}); auto-merge is NOT armed.`
54
+ : ' The branch is published; auto-merge is NOT armed.',
55
+ ' The task stays at completed. Approve promotes it to land; send-back returns it to the /tweak queue.',
56
+ ' Nothing more to do here.',
57
+ ];
58
+ }
59
+
60
+ // The auto-merge result the publish route reports for a held pass, in the shape
61
+ // enableAutoMergeDetailed returns, so the route's response carries its reason
62
+ // on the field it already has (auto_merge_reason).
63
+ const HELD_NOT_ARMED = Object.freeze({
64
+ ok: false,
65
+ reason: 'held for the artist (ADR 0341 D7): a page tweak lands only after its artist approves it',
66
+ });
67
+
68
+ module.exports = {
69
+ HELD_NOT_ARMED,
70
+ HOLD_STATE,
71
+ isPageTweak,
72
+ isHeldForArtist,
73
+ heldLines,
74
+ };
@@ -12,6 +12,7 @@
12
12
  'use strict';
13
13
 
14
14
  const api = require('../../src/module-api');
15
+ const hold = require('./page-tweak-hold');
15
16
 
16
17
  // Every page-tweak task, oldest first, with what the round state (D7) and the
17
18
  // changelog (D8) need beside the task itself:
@@ -93,4 +94,35 @@ async function shippedTasksTouchingSince({ files, since, limit = 50 } = {}) {
93
94
  return rows;
94
95
  }
95
96
 
96
- module.exports = { listPageTweakTasks, shippedTasksTouchingSince };
97
+ // Is this task a page tweak held for its artist (ADR 0341 D7; task 1004322)?
98
+ // The publish routes ask it before they arm or nudge a merge: a held pass is
99
+ // published so the Approval queue can show the applied branch, but it must not
100
+ // land until the artist approves. One row: the task's source and status, and its
101
+ // grade (task_grades keeps one row per task). The verdict is the shared pure
102
+ // predicate, so this read and applyGrade cannot disagree about what "held" means.
103
+ async function readTweakHold(taskId) {
104
+ const { rows } = await api.pool.query(
105
+ `SELECT t.source, t.status, g.passed AS grade_passed
106
+ FROM tasks t
107
+ LEFT JOIN task_grades g ON g.task_id = t.id
108
+ WHERE t.id = $1`,
109
+ [taskId]
110
+ );
111
+ const row = rows[0] || null;
112
+ return { held: !!row && hold.isHeldForArtist(row, row.grade_passed), page_tweak: hold.isPageTweak(row) };
113
+ }
114
+
115
+ // The publish routes' form of the question, FAIL-CLOSED: a read that throws
116
+ // answers "held", so the route neither arms auto-merge nor nudges the merge.
117
+ // That is the safe direction for every task: an ordinary confirmed task whose
118
+ // PR was left un-armed still lands, because the publish reconciler's sweep
119
+ // merges green PRs of confirmed tasks on its own (task 1162).
120
+ async function heldForArtistFailClosed(taskId, read = readTweakHold) {
121
+ try {
122
+ return (await read(taskId)).held;
123
+ } catch (_) {
124
+ return true;
125
+ }
126
+ }
127
+
128
+ module.exports = { listPageTweakTasks, shippedTasksTouchingSince, heldForArtistFailClosed };
@@ -24,6 +24,11 @@ const express = require('express');
24
24
  const api = require('../../../src/module-api');
25
25
  const auth = api;
26
26
  const githubPush = require('../github-push');
27
+ // The page-tweak hold (task 1004323 / BV2.TW12, ADR 0341 D7): the approve below
28
+ // re-arms auto-merge on the reopened PR, and a page tweak held for its artist must
29
+ // never be armed from here. Called through the module object so tests can stub it.
30
+ const pageTweakReads = require('../page-tweak-reads');
31
+ const { HELD_NOT_ARMED } = require('../page-tweak-hold');
27
32
  const { parseId } = api;
28
33
  // The SAME pure classifier the CI gate runs — reused (never duplicated) so the hall
29
34
  // shows identical verdict + reasons. Safe to require: gate-review.js guards its CLI
@@ -168,9 +173,16 @@ module.exports = function buildGateApprovalsRouter() {
168
173
 
169
174
  // 2. Re-trigger gate-review (close+reopen → `pull_request: reopened`) so the
170
175
  // required check re-evaluates the new approval; re-arm auto-merge.
176
+ // A PAGE TWEAK HELD FOR ITS ARTIST is reopened but NOT re-armed (task
177
+ // 1004323): clearing the gate check is the owner's act on the PR, and
178
+ // approving the page is the artist's (ADR 0341 D7). Asked fail-closed: a
179
+ // read that throws answers held, and an ordinary confirmed task left
180
+ // un-armed still lands through the publish reconciler's sweep.
181
+ const prTask = taskIdFromPr(pr);
182
+ const held = prTask != null && await pageTweakReads.heldForArtistFailClosed(prTask);
171
183
  let recheck = { reopened: false, auto_merge: false };
172
184
  try {
173
- recheck = await githubPush.recheckPullRequest({ number });
185
+ recheck = await githubPush.recheckPullRequest(held ? { number, arm: false, heldReason: HELD_NOT_ARMED.reason } : { number });
174
186
  } catch (e) {
175
187
  // The status IS set; if the re-trigger failed, the next push or a manual
176
188
  // re-run still clears it, and the publish reconciler sweep merges once green.
@@ -187,7 +199,10 @@ module.exports = function buildGateApprovalsRouter() {
187
199
  head_sha: pr.head_sha,
188
200
  status_set: true,
189
201
  recheck,
190
- message: 'Approval recorded. Re-running the gate check — the PR merges once it goes green.',
202
+ held_for_artist: held,
203
+ message: held
204
+ ? '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.'
205
+ : 'Approval recorded. Re-running the gate check — the PR merges once it goes green.',
191
206
  });
192
207
  } catch (err) {
193
208
  console.error('[gds] POST /gate-approvals/:pr/approve', err);
@@ -35,6 +35,7 @@ const { depRefusalRow } = require('../dead-deps.js');
35
35
  const { gradeQualityWhere } = require('../db-shared');
36
36
  const auth = api;
37
37
  const db = require('../db');
38
+ const pageTweakReads = require('../page-tweak-reads'); const { HELD_NOT_ARMED } = require('../page-tweak-hold'); // ADR 0341 D7 (tests stub the read)
38
39
  const hierarchy = require('../hierarchy');
39
40
  const { suggestCriteria } = require('../criterion-suggest');
40
41
  // The orphan-task advisory (task 1002539) — pure, fail-open; see goal-advisory.js.
@@ -1184,7 +1185,8 @@ module.exports = function buildTasksRouter() {
1184
1185
  // task 1001431: ask WHY when arming fails. `auto_merge: false` on its own told the
1185
1186
  // builder nothing, and the commonest cause is a repo with allow_auto_merge OFF —
1186
1187
  // a permanent condition that reads exactly like a transient blip.
1187
- const autoMergeResult = await githubPush.enableAutoMergeDetailed({ nodeId: pr.node_id });
1188
+ // task 1004322 (ADR 0341 D7): a page tweak held for its artist is PUBLISHED, never armed.
1189
+ const autoMergeResult = (await pageTweakReads.heldForArtistFailClosed(id)) ? HELD_NOT_ARMED : await githubPush.enableAutoMergeDetailed({ nodeId: pr.node_id });
1188
1190
  const autoMerge = autoMergeResult.ok;
1189
1191
  // task 1139 (ADR 0058): attest the author's trust tier on the pushed sha so the
1190
1192
  // gate-review check can auto-approve a Metic+ author's gate-surface PR with no
@@ -1326,7 +1328,7 @@ module.exports = function buildTasksRouter() {
1326
1328
  // mirrors the reconcile-on-read precedent above (a GET that finishes the
1327
1329
  // land); best-effort so a merge-driver miss never turns the poll into a 502.
1328
1330
  let merge_driver = null;
1329
- if (!reconciled && !status.merged && status.pr_state === 'open') {
1331
+ if (!reconciled && !status.merged && status.pr_state === 'open' && !(await pageTweakReads.heldForArtistFailClosed(id))) { // a held page tweak is never nudged (ADR 0341 D7)
1330
1332
  try {
1331
1333
  merge_driver = await githubPush.mergeIfGreen({ branch });
1332
1334
  } catch (e) {
@@ -14,6 +14,12 @@
14
14
  // DELETE /tasks/:id/visual take it back off (claim holder | Archon)
15
15
  // GET /task-visuals/:name serve one (see the audience note)
16
16
  //
17
+ // NAMED SLOTS (task 1004322 / BV2.TW11, lifecycle_013) — the same store, the same
18
+ // gate, the same audience, one picture per name:
19
+ // POST /tasks/:id/visuals/:slot attach or replace a named one (claim holder | Archon)
20
+ // DELETE /tasks/:id/visuals/:slot take it back off (claim holder | Archon)
21
+ // GET /tasks/:id/visuals list them by name (any builder)
22
+ //
17
23
  // The twin of routes/bug-attachments.js, for the image a builder attaches to
18
24
  // their own shipped task instead of one the bot pulled out of #bugs. Storage +
19
25
  // screening live in ../task-visuals.js; all three routes live here rather than
@@ -191,6 +197,82 @@ module.exports = function buildTaskVisualsRouter() {
191
197
  }
192
198
  });
193
199
 
200
+ // ==========================================================================
201
+ // NAMED SLOTS (task 1004322 / BV2.TW11; ADR 0341 D7, builder pick 9)
202
+ // --------------------------------------------------------------------------
203
+ // A page tweak carries eight renders of the applied page (before and after,
204
+ // desktop and phone, light and dark), and the Approval queue shows them by
205
+ // name. Each slot is stored and screened exactly as the one visual is, and
206
+ // served by the same GET /task-visuals/:name, whose audience comes from the
207
+ // task. The slot name is validated before any byte is read.
208
+
209
+ // POST /tasks/:id/visuals/:slot — attach (or replace) one named visual.
210
+ router.post('/tasks/:id/visuals/:slot', auth.requireBuilder, visualUpload, async (req, res) => {
211
+ const id = parseId(req, res, { code: 'bad_task_id' });
212
+ if (id === null) return;
213
+ const slot = req.params.slot;
214
+ if (!visuals.isSafeSlot(slot)) return res.fail('bad_slot', 400, { max_length: visuals.MAX_SLOT_LENGTH, example: visuals.TWEAK_RENDER_SLOTS[0] });
215
+ if (validateOrRespond(req, res, {
216
+ image_b64: { required: true, type: 'string', maxLength: VISUAL_B64_MAX, minLength: 1 },
217
+ content_type: { required: true, type: 'string', maxLength: 100, minLength: 1 },
218
+ alt: { type: 'string', maxLength: visuals.MAX_ALT_LENGTH },
219
+ })) return;
220
+ if (!(await gateTaskOwnership(req, res, id))) return;
221
+ const buf = Buffer.from(req.body.image_b64, 'base64');
222
+ const screened = visuals.screenImage(buf, req.body.content_type);
223
+ if (!screened.ok) {
224
+ return res.fail(`visual_${screened.reason}`, screened.reason === 'too_big' ? 413 : 400, {
225
+ max_bytes: visuals.MAX_BYTES,
226
+ accepted: Object.keys(visuals.EXT_BY_TYPE),
227
+ });
228
+ }
229
+ const task = await db.getTaskVisualAccess(id);
230
+ if (!task) return res.fail('task_not_found', 404);
231
+ try {
232
+ const stored = await visuals.persistImageBuffer(buf, { taskId: id, contentType: req.body.content_type });
233
+ if (!stored.ok) return res.fail(`visual_${stored.reason}`, 400);
234
+ const row = await db.setTaskVisualSlot({ taskId: id, slot, visualUrl: stored.url, visualAlt: visuals.normalizeAlt(req.body.alt) });
235
+ if (row && row.replaced_url && row.replaced_url !== stored.url) await visuals.deleteStored(row.replaced_url);
236
+ return res.status(201).json({ ok: true, slot, visual_url: row.visual_url, visual_alt: row.visual_alt, bytes: stored.bytes });
237
+ } catch (err) {
238
+ log.error({ taskId: id, slot, err: err && err.message }, 'task-visual slot upload failed');
239
+ return res.fail('visual_store_failed', { status: 500, message: 'internal error' });
240
+ }
241
+ });
242
+
243
+ // DELETE /tasks/:id/visuals/:slot — remove one named visual. Idempotent.
244
+ router.delete('/tasks/:id/visuals/:slot', auth.requireBuilder, async (req, res) => {
245
+ const id = parseId(req, res, { code: 'bad_task_id' });
246
+ if (id === null) return;
247
+ const slot = req.params.slot;
248
+ if (!visuals.isSafeSlot(slot)) return res.fail('bad_slot', 400);
249
+ if (!(await gateTaskOwnership(req, res, id))) return;
250
+ try {
251
+ const url = await db.deleteTaskVisualSlot({ taskId: id, slot });
252
+ if (url) await visuals.deleteStored(url);
253
+ return res.json({ ok: true, slot, visual_url: null });
254
+ } catch (err) {
255
+ log.error({ taskId: id, slot, err: err && err.message }, 'task-visual slot delete failed');
256
+ return res.fail('visual_delete_failed', { status: 500, message: 'internal error' });
257
+ }
258
+ });
259
+
260
+ // GET /tasks/:id/visuals — the task's named visuals. Any signed-in builder: it
261
+ // lists URLs only, and each image is still served under its own task's audience.
262
+ router.get('/tasks/:id/visuals', auth.requireBuilder, async (req, res) => {
263
+ const id = parseId(req, res, { code: 'bad_task_id' });
264
+ if (id === null) return;
265
+ try {
266
+ const task = await db.getTaskVisualAccess(id);
267
+ if (!task) return res.fail('task_not_found', 404);
268
+ const rows = await db.listTaskVisualSlots(id);
269
+ return res.json({ ok: true, task_id: String(id), slots: rows.map((x) => ({ slot: x.slot, visual_url: x.visual_url, visual_alt: x.visual_alt, updated_at: x.updated_at })) });
270
+ } catch (err) {
271
+ log.error({ taskId: id, err: err && err.message }, 'task-visual slot list failed');
272
+ return res.fail('visual_list_failed', { status: 500, message: 'internal error' });
273
+ }
274
+ });
275
+
194
276
  // DELETE /tasks/:id/visual — remove it again. The counterpart to "always
195
277
  // optional": a builder who attached the wrong screenshot must be able to take
196
278
  // it back, and an image is not prose (the write-once rule of task 1003102
@@ -43,4 +43,43 @@ async function getTaskVisualAccess(taskId) {
43
43
  return rows[0] ?? null;
44
44
  }
45
45
 
46
- module.exports = { setTaskVisual, getTaskVisualAccess };
46
+ // NAMED SLOTS (task 1004322, lifecycle_013). Status-blind, like the two above:
47
+ // the routes decide who may write and who may see.
48
+ //
49
+ // Upsert one slot. Returns the stored row and the URL it REPLACED (null when the
50
+ // slot was empty), read in the same statement so the route can unlink exactly the
51
+ // superseded file.
52
+ async function setTaskVisualSlot({ taskId, slot, visualUrl, visualAlt = null }) {
53
+ const { rows } = await pool.query(
54
+ `WITH prev AS (
55
+ SELECT visual_url FROM lifecycle_task_visual_slots WHERE task_id = $1 AND slot = $2
56
+ )
57
+ INSERT INTO lifecycle_task_visual_slots (task_id, slot, visual_url, visual_alt, updated_at)
58
+ VALUES ($1, $2, $3, $4, now())
59
+ ON CONFLICT (task_id, slot) DO UPDATE
60
+ SET visual_url = EXCLUDED.visual_url, visual_alt = EXCLUDED.visual_alt, updated_at = now()
61
+ RETURNING slot, visual_url, visual_alt, (SELECT visual_url FROM prev) AS replaced_url`,
62
+ [taskId, slot, visualUrl, visualAlt]
63
+ );
64
+ return rows[0] ?? null;
65
+ }
66
+
67
+ // Remove one slot. Returns the URL it held, or null when it was already empty.
68
+ async function deleteTaskVisualSlot({ taskId, slot }) {
69
+ const { rows } = await pool.query(
70
+ `DELETE FROM lifecycle_task_visual_slots WHERE task_id = $1 AND slot = $2 RETURNING visual_url`,
71
+ [taskId, slot]
72
+ );
73
+ return rows[0] ? rows[0].visual_url : null;
74
+ }
75
+
76
+ // Every slot a task carries, by name.
77
+ async function listTaskVisualSlots(taskId) {
78
+ const { rows } = await pool.query(
79
+ `SELECT slot, visual_url, visual_alt, updated_at FROM lifecycle_task_visual_slots WHERE task_id = $1 ORDER BY slot`,
80
+ [taskId]
81
+ );
82
+ return rows;
83
+ }
84
+
85
+ module.exports = { setTaskVisual, getTaskVisualAccess, setTaskVisualSlot, deleteTaskVisualSlot, listTaskVisualSlots };
@@ -55,6 +55,24 @@ const TYPE_BY_EXT = {
55
55
  };
56
56
 
57
57
  const MAX_BYTES = 4 * 1024 * 1024; // 4 MB — a screenshot, not an asset library.
58
+
59
+ // NAMED SLOTS (task 1004322 / BV2.TW11, lifecycle_013). A task may carry named
60
+ // visuals beside its one ship-time picture. The shape is the column's own CHECK,
61
+ // restated so a bad name is refused before any byte is stored.
62
+ const SLOT_RE = /^[a-z0-9]+(-[a-z0-9]+){0,5}$/;
63
+ const MAX_SLOT_LENGTH = 48;
64
+ function isSafeSlot(slot) {
65
+ return typeof slot === 'string' && slot.length <= MAX_SLOT_LENGTH && SLOT_RE.test(slot);
66
+ }
67
+ // The eight renders /tweak attaches to a page tweak (ADR 0341 D7, builder pick 9):
68
+ // the applied page before and after, desktop (1440) and phone (390), light and
69
+ // dark. Declared ONCE: the renderer names its files with it, the attach step
70
+ // uploads by it, and the Approval queue (TW12) reads the same names.
71
+ const TWEAK_RENDER_PHASES = Object.freeze(['before', 'after']);
72
+ const TWEAK_RENDER_DEVICES = Object.freeze({ desktop: 1440, phone: 390 });
73
+ const TWEAK_RENDER_MODES = Object.freeze(['light', 'dark']);
74
+ const TWEAK_RENDER_SLOTS = Object.freeze(TWEAK_RENDER_PHASES.flatMap((phase) =>
75
+ Object.keys(TWEAK_RENDER_DEVICES).flatMap((device) => TWEAK_RENDER_MODES.map((mode) => `${phase}-${device}-${mode}`))));
58
76
  const MAX_ALT_LENGTH = 300; // the caption/alt text stored beside it.
59
77
 
60
78
  // Stored filenames are SERVER-generated, never user-supplied: a fixed shape we
@@ -249,6 +267,12 @@ async function deleteStored(nameOrUrl, { dir = visualsDir() } = {}) {
249
267
  }
250
268
 
251
269
  module.exports = {
270
+ isSafeSlot,
271
+ MAX_SLOT_LENGTH,
272
+ TWEAK_RENDER_PHASES,
273
+ TWEAK_RENDER_DEVICES,
274
+ TWEAK_RENDER_MODES,
275
+ TWEAK_RENDER_SLOTS,
252
276
  EXT_BY_TYPE,
253
277
  TYPE_BY_EXT,
254
278
  MAX_BYTES,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1063",
3
+ "version": "1.19.1065",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1063",
9
+ "version": "1.19.1065",
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.1063",
3
+ "version": "1.19.1065",
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",
@@ -7679,5 +7679,17 @@
7679
7679
  "id": "1004321",
7680
7680
  "text": "Artists now have their own editor for a page. It shows every word of the page as one sheet of paper over the studio at night. You type over any line and it saves as you go. A changed line gets a small orange mark and shows wha"
7681
7681
  }
7682
+ ],
7683
+ "1.19.1064": [
7684
+ {
7685
+ "id": "1004322",
7686
+ "text": "A session can now apply an artist's rewritten page. The new /tweak skill takes the next submitted page, applies every line in one pass, and photographs the page before and after, on a desktop and a phone, in light and dark, at"
7687
+ }
7688
+ ],
7689
+ "1.19.1065": [
7690
+ {
7691
+ "id": "1004323",
7692
+ "text": "An artist can now approve a tweaked page from the studio. A new Approval queue button opens a glass panel that shows the applied page as real pictures, before and after, on desktop and phone, in light and dark, with the change"
7693
+ }
7682
7694
  ]
7683
7695
  }