@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,327 @@
1
+ // tests/hall_approval_queue.mjs — the Approval queue panel (task 1004323 /
2
+ // BV2.TW12, ADR 0341 D7; docs/design/mocks/tweak-mode/Main.dc.html).
3
+ //
4
+ // Boots the REAL panel: approval-queue-lib.js and approval-queue.js in a vm
5
+ // over a mini-DOM, against a stub client. What the client answers for the
6
+ // queue is built by the REAL composer (page-status.js composeApprovals) over
7
+ // round descriptions the REAL pages.js wrote, so the panel is tested on the
8
+ // route's own shape, never a hand-written literal of it.
9
+ //
10
+ // The done-when, as far as a panel can prove it: open the queue from the studio
11
+ // (1 of 2), flip before and after over the applied page's renders, approve and
12
+ // ship (the page's next status and the next page named), send one back with a
13
+ // note, with no terminal.
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { createRequire } from 'node:module';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import vm from 'node:vm';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { makeRunner, makeEl, walk, settle } from './helpers.mjs';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+ const PUB = path.join(ROOT, 'modules/hall-ui/public');
26
+ const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
27
+ const lib = require('../modules/hall-ui/public/approval-queue-lib.js');
28
+ const pages = require('../modules/copy-desk/pages.js');
29
+ const pageStatus = require('../modules/copy-desk/page-status.js');
30
+
31
+ const { test, summary } = makeRunner();
32
+
33
+ // ── the queue, through the real composer ───────────────────────────────────
34
+ const INVENTORY = {
35
+ pages: [
36
+ { id: 'landing:index', surface: 'landing', title: '{{productName}}: {{tagline}}', path: '/' },
37
+ { id: 'landing:projects', surface: 'landing', title: 'Projects', path: '/projects' },
38
+ ],
39
+ };
40
+ const ME = '7';
41
+ function round({ id, pageId, n, lines, refused = [], appliedAt, artist = ME }) {
42
+ const description = [
43
+ `Page tweak round ${n}.`,
44
+ pages.composeBatchBlock({ page_id: pageId, reading_hash: 'h', submitted_at: '2026-09-19T00:00:00Z', lines: lines.map((l) => ({ ...l, placement: 'placed', file: 'x.html', line: 1, string_id: 'a' })) }),
45
+ pages.composeAppliedBlock({ page_id: pageId, applied_at: appliedAt, reading_hash_after: 'h2', lines_after: lines.map((l) => l.after), refused }),
46
+ ].join('\n\n');
47
+ return { id, status: 'completed', grade_passed: true, description, source_ref: pages.roundSourceRef(pageId, n), holder_id: null, artist_id: artist, artist_login: 'me' };
48
+ }
49
+ const TASKS = [
50
+ { id: 40, status: 'shipped', description: '', source_ref: pages.roundSourceRef('landing:index', 1), shipped_at: '2026-09-01T00:00:00Z' },
51
+ round({
52
+ id: 41, pageId: 'landing:index', n: 2, appliedAt: '2026-09-21T00:00:00Z',
53
+ lines: [
54
+ { key: 'L0003', section: 'hero', before: 'AI agents claim tasks and ship the code.', after: 'Agents write the code.' },
55
+ { key: 'L0004', section: 'hero', before: '<img src=x onerror="window.pwned=1">', after: 'safe' },
56
+ ],
57
+ refused: [{ key: 'L0004', code: 'target_gone' }],
58
+ }),
59
+ round({ id: 42, pageId: 'landing:projects', n: 1, appliedAt: '2026-09-22T00:00:00Z', lines: [{ key: 'L0002', section: 'list', before: 'Every project', after: 'All the projects' }] }),
60
+ ];
61
+ const queueFor = ({ crafts = ['artist'], tasks = TASKS } = {}) => ({ ok: true, ...pageStatus.composeApprovals({ inventory: INVENTORY, tasks, builderId: ME, crafts }) });
62
+ const url = (task, slot) => `/api/bongos/task-visuals/task-${task}-${String(slot.length).padStart(2, '0')}${'ab'.repeat(7)}.jpg`;
63
+ const slotsFor = (task, only = lib.PHASES.flatMap((p) => lib.DEVICES.flatMap((d) => lib.MODES.map((m) => lib.slotName(p, d, m))))) =>
64
+ ({ ok: true, task_id: String(task), slots: only.map((slot) => ({ slot, visual_url: url(task, slot), visual_alt: slot })) });
65
+
66
+ // vm-born objects fail a strict deepEqual on their prototype, so what the panel
67
+ // sent is compared through JSON.
68
+ // ── the mini-DOM ────────────────────────────────────────────────────────────
69
+ function augment(el) {
70
+ if (el._aug) return el;
71
+ el._aug = true;
72
+ Object.defineProperty(el, 'hidden', { get() { return 'hidden' in el.attrs; }, set(v) { if (v) el.attrs.hidden = ''; else delete el.attrs.hidden; }, configurable: true });
73
+ el.focus = () => { boot.focused = el; };
74
+ return el;
75
+ }
76
+ let boot = null;
77
+
78
+ function bootPanel({ answers = {}, writes = null, mode = 'light' } = {}) {
79
+ const body = augment(makeEl('body', {}));
80
+ const classes = new Set();
81
+ const requests = [];
82
+ const counts = [];
83
+ const byId = (id) => walk(body, (c) => (c.id === id ? c : null));
84
+ const documentObj = {
85
+ body,
86
+ documentElement: {
87
+ getAttribute: (k) => (k === 'data-theme' ? mode : null),
88
+ classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c), contains: (c) => classes.has(c) },
89
+ },
90
+ getElementById: byId,
91
+ createElement: (tag) => augment(makeEl(tag, {})),
92
+ get activeElement() { return boot.focused || null; },
93
+ };
94
+ const api = {
95
+ async request(method, u, reqBody) {
96
+ requests.push({ method, url: u, body: reqBody });
97
+ const key = `${method} ${u}`;
98
+ const hit = answers[key];
99
+ const val = typeof hit === 'function' ? hit(reqBody) : hit;
100
+ if (val === undefined) return { ok: false, status: 404, data: { error: { code: 'no_answer', message: key } } };
101
+ return val.__status ? { ok: false, status: val.__status, data: val.data } : { ok: true, status: 200, data: val };
102
+ },
103
+ };
104
+ const win = { matchMedia: () => ({ matches: false }) };
105
+ const ctx = vm.createContext({ window: win, document: documentObj, console, setTimeout, clearTimeout, encodeURIComponent });
106
+ win.window = win;
107
+ vm.runInContext(read('approval-queue-lib.js'), ctx, { filename: 'approval-queue-lib.js' });
108
+ vm.runInContext(read('approval-queue.js'), ctx, { filename: 'approval-queue.js' });
109
+ const panel = win.OTBApprovalQueue.mount({
110
+ api,
111
+ apiBase: '/api/bongos',
112
+ writes: writes || {
113
+ approve: (p) => `/api/bongos/copy-desk/pages/${p}/approve`,
114
+ sendBack: (p) => `/api/bongos/copy-desk/pages/${p}/send-back`,
115
+ },
116
+ onCount: (n) => counts.push(n),
117
+ });
118
+ const scrim = body.children[0];
119
+ const q = (sel) => scrim.querySelector(sel);
120
+ const qa = (sel) => scrim.querySelectorAll(sel);
121
+ const click = async (el) => { assert.ok(el, 'the control exists'); scrim.fire('click', { target: el }); await settle(); await settle(); };
122
+ boot = { body, scrim, q, qa, click, requests, counts, panel, classes, win, focused: null };
123
+ return boot;
124
+ }
125
+
126
+ const APPROVALS = 'GET /api/bongos/copy-desk/approvals';
127
+ const vis = (id) => `GET /api/bongos/tasks/${id}/visuals`;
128
+ const text = (el) => (el ? el.textContent.replace(/\s+/g, ' ').trim() : null);
129
+
130
+ // ── the pure rules: every word the panel says ──────────────────────────────
131
+
132
+ await test('lib: the artboard\'s words, from the route\'s own shape', () => {
133
+ const [home, projects] = queueFor().queue;
134
+ assert.equal(lib.eyebrow(home), 'approval queue · 1 of 2');
135
+ assert.equal(lib.eyebrow(projects), 'approval queue · 2 of 2');
136
+ assert.equal(lib.displayTitle(home), 'Home', 'an apex template title is Home, the artboard\'s own word');
137
+ assert.equal(lib.subLine(home), '· landing · 1 line rewritten, applied · 1 not applied');
138
+ assert.equal(lib.subLine(projects), '· landing · 1 line rewritten, applied');
139
+ assert.equal(lib.doorDetail(2), '2 pages applied, waiting for you');
140
+ assert.equal(lib.doorDetail(1), '1 page applied, waiting for you');
141
+ assert.equal(lib.doorDetail(0), 'nothing waiting for you');
142
+ assert.equal(lib.approvedLine(home, { status_after: 'Text tweaked 3x', next: { page: { title: 'Contact' } } }),
143
+ 'Approved. It ships now, and Home reads text tweaked 3x. Next in the queue: Contact.', 'the artboard line, word for word');
144
+ assert.equal(lib.approvedLine(home, { status_after: 'Text tweaked 2x', next: { page: null } }), 'Approved. It ships now, and Home reads text tweaked 2x. Nothing else is waiting.');
145
+ assert.equal(lib.sentBackLine(), 'Sent back with your note. It comes back to the queue once it is re-applied.');
146
+ assert.equal(lib.noteProblem('too long'), 'Say what needs changing in at least 10 characters.');
147
+ assert.equal(lib.noteProblem('The hero line is too long on a phone.'), null);
148
+ });
149
+
150
+ await test('lib: renders are picked by view, fall back to the nearest, and only server-minted URLs are kept', () => {
151
+ const idx = lib.renderIndex([
152
+ { slot: 'after-desktop-dark', visual_url: url(41, 'after-desktop-dark') },
153
+ { slot: 'after-phone-light', visual_url: url(41, 'after-phone-light') },
154
+ { slot: 'before-desktop-light', visual_url: 'https://evil.example/x.jpg' },
155
+ { slot: 'not-a-render', visual_url: url(41, 'not-a-render') },
156
+ ]);
157
+ assert.deepEqual(Object.keys(idx).sort(), ['after-desktop-dark', 'after-phone-light'], 'a foreign URL and an unknown slot are dropped');
158
+ const light = lib.pickRender(idx, { phase: 'after', device: 'desktop', mode: 'light' });
159
+ assert.equal(light.slot, 'after-desktop-dark');
160
+ assert.equal(light.exact, false, 'the other colour mode first, and it says so');
161
+ assert.equal(lib.pickRender(idx, { phase: 'after', device: 'phone', mode: 'light' }).exact, true);
162
+ assert.equal(lib.pickRender(idx, { phase: 'before', device: 'desktop', mode: 'light' }), null);
163
+ });
164
+
165
+ await test('lib: a changed line is marked after, and plain before; a refused line says why', () => {
166
+ const [home] = queueFor().queue;
167
+ const [ok, refused] = home.lines;
168
+ assert.deepEqual(lib.lineRow(ok, 'after'), { key: 'L0003', section: 'hero', text: 'Agents write the code.', marked: true, aside: 'was: AI agents claim tasks and ship the code.', refused: false });
169
+ assert.deepEqual(lib.lineRow(ok, 'before'), { key: 'L0003', section: 'hero', text: 'AI agents claim tasks and ship the code.', marked: false, aside: 'becomes: Agents write the code.', refused: false });
170
+ const r = lib.lineRow(refused, 'after');
171
+ assert.equal(r.marked, false, 'a refused line never looks applied');
172
+ assert.equal(r.aside, 'not applied: the page no longer shows it');
173
+ });
174
+
175
+ // ── the panel ───────────────────────────────────────────────────────────────
176
+
177
+ await test('open: the queue\'s head, the applied page\'s render, and the words beside it', async () => {
178
+ const b = bootPanel({ answers: { [APPROVALS]: queueFor(), [vis(41)]: slotsFor(41) } });
179
+ assert.equal(b.scrim.hidden, true, 'closed until opened');
180
+ await b.panel.open();
181
+ await settle(); await settle();
182
+ assert.equal(b.scrim.hidden, false);
183
+ assert.ok(b.classes.has('aq-open'), 'the page behind stops scrolling');
184
+ assert.equal(text(b.q('.aq-eyebrow')), 'approval queue · 1 of 2');
185
+ assert.equal(text(b.q('.aq-title')), 'Home · landing · 1 line rewritten, applied · 1 not applied');
186
+ const img = b.q('img');
187
+ assert.equal(img.attrs.src, url(41, 'after-desktop-light'), 'the AFTER render, desktop, in the hall\'s own mode');
188
+ assert.equal(img.attrs.alt, 'Home, after, desktop, light');
189
+ assert.deepEqual(b.qa('[data-phase]').map((x) => [text(x), x.attrs['aria-pressed']]), [['before', 'false'], ['after', 'true']]);
190
+ assert.equal(b.qa('mark').length, 1, 'one applied line carries the mark');
191
+ assert.equal(text(b.q('mark')), 'Agents write the code.');
192
+ assert.match(text(b.q('.aq-line--refused')), /not applied: the page no longer shows it/);
193
+ assert.equal(b.body.querySelectorAll('img').length, 1, 'a hostile line is text, never markup');
194
+ assert.equal(b.win.pwned, undefined);
195
+ assert.equal(text(b.q('.aq-hint')), 'highlighted: the words you changed');
196
+ assert.deepEqual(b.counts, [2], 'the door hears how many wait');
197
+ assert.ok(!b.requests.some((r) => r.method !== 'GET'), 'opening writes nothing');
198
+ });
199
+
200
+ await test('the switches: before and after, desktop and phone, light and dark, each a render of the applied branch', async () => {
201
+ const b = bootPanel({ answers: { [APPROVALS]: queueFor(), [vis(41)]: slotsFor(41) } });
202
+ await b.panel.open(); await settle(); await settle();
203
+ await b.click(b.q('[data-phase="before"]'));
204
+ assert.equal(b.q('img').attrs.src, url(41, 'before-desktop-light'));
205
+ assert.equal(b.qa('mark').length, 0, 'before is the page as it was: nothing marked');
206
+ await b.click(b.q('[data-device="phone"]'));
207
+ assert.equal(b.q('img').attrs.src, url(41, 'before-phone-light'));
208
+ assert.match(b.q('img').attrs.class, /aq-render--phone/);
209
+ await b.click(b.q('[data-mode="dark"]'));
210
+ assert.equal(b.q('img').attrs.src, url(41, 'before-phone-dark'));
211
+ assert.equal(b.requests.filter((r) => r.url.endsWith('/visuals')).length, 1, 'the renders are read once per round');
212
+ });
213
+
214
+ await test('a missing render says what it shows instead; no renders at all means nothing to approve from', async () => {
215
+ const b = bootPanel({ answers: { [APPROVALS]: queueFor(), [vis(41)]: slotsFor(41, ['after-desktop-dark']) } });
216
+ await b.panel.open(); await settle(); await settle();
217
+ assert.equal(b.q('img').attrs.src, url(41, 'after-desktop-dark'));
218
+ assert.equal(text(b.q('.aq-frame__swap')), 'No desktop light render; showing desktop dark.');
219
+ const none = bootPanel({ answers: { [APPROVALS]: queueFor(), [vis(41)]: slotsFor(41, []) } });
220
+ await none.panel.open(); await settle(); await settle();
221
+ assert.equal(none.q('img'), null);
222
+ assert.match(text(none.q('.aq-frame__note')), /No render is attached/);
223
+ assert.ok(none.q('[data-approve]').disabled, 'approve waits for a picture of the page');
224
+ assert.ok(!none.q('[data-open-note]').disabled, 'sending it back does not');
225
+ });
226
+
227
+ await test('approve and ship: one POST through the host\'s write, then the artboard\'s line and the next page', async () => {
228
+ const b = bootPanel({
229
+ answers: {
230
+ [APPROVALS]: queueFor(),
231
+ [vis(41)]: slotsFor(41),
232
+ 'POST /api/bongos/copy-desk/pages/landing:index/approve': { ok: true, outcome: 'approved', status_after: 'Text tweaked 2x', next: { count: 1, page: { page_id: 'landing:projects', title: 'Projects', path: '/projects' } } },
233
+ },
234
+ });
235
+ await b.panel.open(); await settle(); await settle();
236
+ await b.click(b.q('[data-approve]'));
237
+ const posts = JSON.parse(JSON.stringify(b.requests.filter((r) => r.method === 'POST')));
238
+ assert.deepEqual(posts, [{ method: 'POST', url: '/api/bongos/copy-desk/pages/landing:index/approve', body: { task_id: '41' } }], 'the round the artist looked at is pinned');
239
+ assert.equal(text(b.q('.aq-outcome')), 'Approved. It ships now, and Home reads text tweaked 2x. Next in the queue: Projects.');
240
+ assert.equal(b.q('[data-approve]'), null, 'decided: the buttons are gone');
241
+ assert.deepEqual(b.counts, [2, 1]);
242
+ await b.click(b.q('[data-next]'));
243
+ assert.equal(text(b.q('.aq-eyebrow')), 'approval queue · 2 of 2');
244
+ assert.equal(text(b.q('.aq-title')).startsWith('Projects'), true);
245
+ });
246
+
247
+ await test('send it back: the note opens in place, a short one is refused here, a real one is sent', async () => {
248
+ const b = bootPanel({
249
+ answers: {
250
+ [APPROVALS]: queueFor(),
251
+ [vis(41)]: slotsFor(41),
252
+ 'POST /api/bongos/copy-desk/pages/landing:index/send-back': (body) => ({ ok: true, outcome: 'sent_back', sent_back: { note: body.note }, next: { count: 1, page: null } }),
253
+ },
254
+ });
255
+ await b.panel.open(); await settle(); await settle();
256
+ await b.click(b.q('[data-open-note]'));
257
+ const input = b.q('#aq-note');
258
+ assert.ok(input, 'the note field opens');
259
+ assert.equal(input.attrs.placeholder, 'The hero line is too long on a phone.');
260
+ assert.equal(b.q('[data-open-note]'), null, 'send it back gives way to the note and send');
261
+ input.value = 'too long';
262
+ await b.click(b.q('[data-send]'));
263
+ assert.equal(b.requests.filter((r) => r.method === 'POST').length, 0, 'a short note never leaves the panel');
264
+ assert.equal(text(b.q('.aq-error')), 'Say what needs changing in at least 10 characters.');
265
+ b.q('#aq-note').value = ' The hero line is too long on a phone. ';
266
+ await b.click(b.q('[data-send]'));
267
+ const posts = JSON.parse(JSON.stringify(b.requests.filter((r) => r.method === 'POST')));
268
+ assert.deepEqual(posts.map((p) => [p.url, p.body]), [['/api/bongos/copy-desk/pages/landing:index/send-back', { task_id: '41', note: 'The hero line is too long on a phone.' }]]);
269
+ assert.equal(text(b.q('.aq-outcome')), 'Sent back with your note. It comes back to the queue once it is re-applied.');
270
+ });
271
+
272
+ await test('a refusal is the server\'s words, and nothing is marked decided', async () => {
273
+ const b = bootPanel({
274
+ answers: {
275
+ [APPROVALS]: queueFor(),
276
+ [vis(41)]: slotsFor(41),
277
+ 'POST /api/bongos/copy-desk/pages/landing:index/approve': { __status: 409, data: { error: { code: 'round_moved', details: { what_this_means: 'The page waiting now is a different round.' } } } },
278
+ },
279
+ });
280
+ await b.panel.open(); await settle(); await settle();
281
+ await b.click(b.q('[data-approve]'));
282
+ assert.equal(text(b.q('.aq-error')), 'The page waiting now is a different round.');
283
+ assert.ok(b.q('[data-approve]'), 'still deciding');
284
+ assert.deepEqual(b.counts, [2]);
285
+ });
286
+
287
+ await test('a builder who may not decide reads the queue with a sentence, not two dead buttons', async () => {
288
+ const b = bootPanel({ answers: { [APPROVALS]: queueFor({ crafts: ['engineer'], tasks: TASKS.map((t) => ({ ...t, artist_id: '99' })) }), [vis(41)]: slotsFor(41) } });
289
+ await b.panel.open(); await settle(); await settle();
290
+ assert.equal(b.q('[data-approve]'), null);
291
+ assert.equal(b.q('[data-open-note]'), null);
292
+ assert.match(text(b.q('.aq-sealed')), /Only the artist who wrote this page, or another artist/);
293
+ });
294
+
295
+ await test('empty, closed with Escape, and focus goes back where it was', async () => {
296
+ const b = bootPanel({ answers: { [APPROVALS]: queueFor({ tasks: [] }) } });
297
+ const opener = { focus() { boot.focused = opener; } };
298
+ b.focused = opener;
299
+ await b.panel.open(); await settle();
300
+ assert.equal(text(b.q('.aq-title')), 'Nothing waiting');
301
+ assert.match(text(b.q('.aq-frame__note')), /Nothing is waiting for you/);
302
+ b.scrim.fire('keydown', { key: 'Escape', preventDefault() {}, target: b.scrim });
303
+ assert.equal(b.scrim.hidden, true);
304
+ assert.equal(b.focused, opener);
305
+ assert.ok(!b.classes.has('aq-open'));
306
+ assert.deepEqual(b.counts, [0]);
307
+ });
308
+
309
+ // ── the writes: only the host's, and the host's are the studio's allowlist ──
310
+
311
+ await test('the panel writes only through the writes it is handed; the studio hands it its own WRITE_ROUTES', () => {
312
+ const src = read('approval-queue.js').replace(/^\s*\/\/.*$/gm, '');
313
+ const sites = [...src.matchAll(/\.request\(\s*'(POST|PATCH|PUT|DELETE)'\s*,\s*([^,)]+)/g)].map((m) => [m[1], m[2].trim()]);
314
+ assert.deepEqual(sites, [['POST', 'url']], 'exactly one write site');
315
+ assert.match(src, /const url = kind === 'approved' \? writes\.approve\(item\.page_id\) : writes\.sendBack\(item\.page_id\);/);
316
+ assert.equal(/copy-desk\/pages|\/visual\b|\/confirm\b|\/ship\b|\/claims\b|proposals/.test(src.replace(/\/copy-desk\/approvals|\/visuals`/g, '')), false,
317
+ 'no route is named here but the two reads');
318
+ const studio = read('studio.js');
319
+ assert.match(studio, /approve: \(pageId\) => `\$\{API\}\/copy-desk\/pages\/\$\{pageId\}\/approve`,/);
320
+ assert.match(studio, /sendBack: \(pageId\) => `\$\{API\}\/copy-desk\/pages\/\$\{pageId\}\/send-back`,/);
321
+ assert.match(studio, /writes: \{ approve: WRITE_ROUTES\.approve, sendBack: WRITE_ROUTES\.sendBack \}/);
322
+ const html = read('studio.html');
323
+ assert.ok(html.indexOf('approval-queue-lib.js') < html.indexOf('approval-queue.js"') && html.indexOf('approval-queue.js"') < html.indexOf('studio.js?'), 'the lib, the panel, then the studio');
324
+ assert.match(html, /id="studio-approvals"[^>]*aria-haspopup="dialog"/);
325
+ });
326
+
327
+ summary();
@@ -429,6 +429,10 @@ const LITERAL_EXEMPT = {
429
429
  // studio, the glass bar, and a sheet of PAPER that stays paper in both modes,
430
430
  // so its palette cannot be the hall's mode-flipping tier. The sky precedent.
431
431
  'tweak-editor.css': 'the tweak editor\'s stage is the artboard\'s own palette (task 1004321, the pixel contract: paper in both modes)',
432
+ // The Approval queue (task 1004323 / BV2.TW12): the glass dialog of
433
+ // docs/design/mocks/tweak-mode/Main.dc.html, built 1:1. Glass over the night
434
+ // studio in both modes, so its palette sits on the panel's own root.
435
+ 'approval-queue.css': 'the Approval queue\'s glass is the artboard\'s own palette (task 1004323, the pixel contract: night glass in both modes)',
432
436
  };
433
437
  const COLOUR_PROPS = 'color|background|background-color|border|border-color|border-top-color|border-bottom-color|fill|stroke|outline|outline-color|box-shadow|text-shadow';
434
438
  const colourLiteralRe = () =>
@@ -0,0 +1,265 @@
1
+ // tests/page_tweak_hold.mjs — a passed page tweak waits at completed for its
2
+ // artist, and nothing else changes (task 1004322 / BV2.TW11; ADR 0341 D7).
3
+ //
4
+ // Four carriers, one per thing the TW03 builder listed:
5
+ // 1. applyGrade does not promote a passed page tweak, and every OTHER grade
6
+ // sends the same SQL as before, plus exactly one read of `source`;
7
+ // 2. publish-branch publishes a held pass without arming auto-merge;
8
+ // 3. ship.js on a held pass publishes (never merges) and says it waits;
9
+ // 4. strand-watch tells a held pass from a failed grade.
10
+ // No DB, no network: fake pools, the real routers, stubbed GitHub reads.
11
+ //
12
+ // Run: node tests/page_tweak_hold.mjs
13
+
14
+ import { strict as assert } from 'node:assert';
15
+ import { createRequire } from 'node:module';
16
+ import http from 'node:http';
17
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
18
+
19
+ process.env.NODE_ENV = 'test';
20
+ const require = createRequire(import.meta.url);
21
+
22
+ const achievements = require('../modules/economy/achievements.js');
23
+ achievements.evaluateAfterShip = async () => [];
24
+ achievements.evaluateAfterCreditAward = async () => [];
25
+ achievements.evaluateAfterClaim = async () => [];
26
+ achievements.reconcileBuilderAchievements = async () => [];
27
+ const seams = require('../src/module-seams.js');
28
+ if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
29
+ const { applyGrade } = require('../modules/lifecycle/db.js');
30
+ const hold = require('../modules/lifecycle/page-tweak-hold.js');
31
+
32
+ const { test, summary } = makeRunner();
33
+
34
+ const PASS = { score: 0.95, passed: true, threshold: 0.7, grader_kind: 'subagent', grader_version: 't', rubric_scores: {}, signals: {}, issues: [], notes_md: null };
35
+ const FAIL = { ...PASS, score: 0.3, passed: false };
36
+
37
+ function gradePool({ source }) {
38
+ return makeSqlAwareClient((s) => {
39
+ if (/SELECT id, title, status, credits_reward, kind FROM tasks/.test(s)) return { rows: [{ id: 7, title: 'x', status: 'completed', credits_reward: 30, kind: 'feature' }] };
40
+ if (/^SELECT source FROM tasks/.test(s)) return { rows: [{ source }] };
41
+ if (/SELECT builder_id FROM claims/.test(s)) return { rows: [{ builder_id: 25 }] };
42
+ return { rows: [] };
43
+ });
44
+ }
45
+ const flat = (q) => q.map((x) => String(x.sql).replace(/\s+/g, ' ').trim());
46
+
47
+ // ---- 1. applyGrade ---------------------------------------------------------
48
+
49
+ await test('a PASSED page tweak is recorded and HELD: no promotion, no merge, held=true', async () => {
50
+ const pool = gradePool({ source: 'page-tweak' });
51
+ const out = await applyGrade({ taskId: 7, builderId: 1, gradeResult: PASS }, { pool });
52
+ assert.equal(out.taskStatus, 'completed');
53
+ assert.equal(out.held, true);
54
+ assert.equal(out.advanceToMerge, false);
55
+ const q = flat(pool._client.queries);
56
+ assert.ok(q.some((s) => /^INSERT INTO task_grades/.test(s)), 'the grade row is still written');
57
+ assert.ok(!q.some((s) => /^UPDATE tasks/.test(s)), 'the task is NOT promoted to confirmed');
58
+ });
59
+
60
+ await test('an ordinary pass is promoted exactly as before: the same SQL plus ONE read of source', async () => {
61
+ const pool = gradePool({ source: 'planning-session' });
62
+ const out = await applyGrade({ taskId: 7, builderId: 1, gradeResult: PASS }, { pool });
63
+ assert.equal(out.taskStatus, 'confirmed');
64
+ assert.equal(out.held, false);
65
+ assert.equal(out.advanceToMerge, true);
66
+ const q = flat(pool._client.queries);
67
+ const reads = q.filter((s) => /^SELECT source FROM tasks/.test(s));
68
+ assert.equal(reads.length, 1, 'one extra read, and only one');
69
+ // The pre-change statement sequence, with that one read removed, is unchanged.
70
+ const legacy = q.filter((s) => !/^SELECT source FROM tasks/.test(s));
71
+ const pool2 = gradePool({ source: null });
72
+ await applyGrade({ taskId: 7, builderId: 1, gradeResult: PASS }, { pool: pool2 });
73
+ assert.deepEqual(flat(pool2._client.queries).filter((s) => !/^SELECT source FROM tasks/.test(s)), legacy, 'a null source takes the same path');
74
+ assert.ok(legacy.some((s) => /^UPDATE tasks SET status = 'confirmed'/.test(s)));
75
+ });
76
+
77
+ await test('a FAILED grade never asks about the source (the hold is asked only on a pass)', async () => {
78
+ for (const source of ['page-tweak', 'planning-session']) {
79
+ const pool = gradePool({ source });
80
+ const out = await applyGrade({ taskId: 7, builderId: 1, gradeResult: FAIL }, { pool });
81
+ assert.equal(out.taskStatus, 'completed');
82
+ assert.equal(out.held, false);
83
+ assert.ok(!flat(pool._client.queries).some((s) => /^SELECT source FROM tasks/.test(s)));
84
+ }
85
+ });
86
+
87
+ await test('the hold predicate: only a page tweak, only at completed, only a strict pass', () => {
88
+ const t = (source, status) => ({ source, status });
89
+ assert.equal(hold.isHeldForArtist(t('page-tweak', 'completed'), true), true);
90
+ assert.equal(hold.isHeldForArtist(t('page-tweak', 'completed'), false), false);
91
+ assert.equal(hold.isHeldForArtist(t('page-tweak', 'completed'), null), false);
92
+ assert.equal(hold.isHeldForArtist(t('page-tweak', 'confirmed'), true), false);
93
+ assert.equal(hold.isHeldForArtist(t('page-tweak-unplaced', 'completed'), true), false, 'the engineer task an unplaced line files is ordinary work');
94
+ assert.equal(hold.isHeldForArtist(t('copy-proposal', 'completed'), true), false);
95
+ assert.equal(hold.isHeldForArtist(null, true), false);
96
+ });
97
+
98
+ // ---- 2. publish-branch -----------------------------------------------------
99
+
100
+ const express = require('express');
101
+ const api = require('../src/module-api.js');
102
+ const db = require('../modules/lifecycle/db.js');
103
+ const gp = require('../modules/lifecycle/github-push.js');
104
+ const prAssign = require('../modules/lifecycle/pr-assign.js');
105
+ const pageTweakReads = require('../modules/lifecycle/page-tweak-reads.js');
106
+
107
+ let heldNow = false;
108
+ let armCalls = 0;
109
+ api.requireBuilder = (req, res, next) => { req.builder = { id: 1, rank: 'archon', github_login: 'the-archon' }; next(); };
110
+ db.getLastClaimHolder = async () => ({ builder_id: 5, github_login: 'some-builder' });
111
+ db.setClaimPublishedSha = async () => {};
112
+ gp.isConfigured = () => true;
113
+ gp.pushBundle = async () => ({ head_sha: 'a'.repeat(40) });
114
+ gp.openOrFindPullRequest = async () => ({ number: 77, url: 'https://github.com/o/r/pull/77', node_id: 'N' });
115
+ gp.enableAutoMergeDetailed = async () => { armCalls++; return { ok: true }; };
116
+ gp.setCommitStatus = async () => true;
117
+ prAssign.assignPullRequest = async () => ({ assigned: true });
118
+ pageTweakReads.heldForArtistFailClosed = async () => heldNow;
119
+
120
+ const app = express();
121
+ app.use((req, res, next) => { res.fail = (code, status, extra) => res.status(typeof status === 'number' ? status : (status && status.status) || 500).json({ error: code, ...(extra || {}) }); next(); });
122
+ app.use(require('../modules/lifecycle/routes/tasks.js')());
123
+ const server = http.createServer(app);
124
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
125
+ const base = `http://127.0.0.1:${server.address().port}`;
126
+ const publish = async () => {
127
+ const res = await fetch(`${base}/tasks/5001/publish-branch`, {
128
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
129
+ body: JSON.stringify({ branch: 'task-5001', head_sha: 'a'.repeat(40), bundle_b64: Buffer.from('b').toString('base64'), value_summary: 'Page tweak.' }),
130
+ });
131
+ return { status: res.status, body: await res.json() };
132
+ };
133
+
134
+ await test('publish-branch on a HELD pass: the PR is opened, auto-merge is NOT armed, and the reason says why', async () => {
135
+ heldNow = true; armCalls = 0;
136
+ const r = await publish();
137
+ assert.equal(r.status, 201, JSON.stringify(r.body));
138
+ assert.equal(r.body.pr_url, 'https://github.com/o/r/pull/77', 'published: the Approval queue can show the branch');
139
+ assert.equal(r.body.auto_merge, false);
140
+ assert.equal(armCalls, 0, 'GitHub is never asked to arm it');
141
+ assert.match(r.body.auto_merge_reason, /artist/);
142
+ });
143
+
144
+ await test('publish-branch on every other task arms auto-merge exactly as before', async () => {
145
+ heldNow = false; armCalls = 0;
146
+ const r = await publish();
147
+ assert.equal(r.status, 201);
148
+ assert.equal(r.body.auto_merge, true);
149
+ assert.equal(armCalls, 1);
150
+ assert.equal(r.body.auto_merge_reason, undefined);
151
+ });
152
+ server.close();
153
+
154
+ // ---- 3. ship.js ------------------------------------------------------------
155
+
156
+ const shipMerge = require('../scripts/gds/ship-merge.js');
157
+ const shipFinish = require('../scripts/gds/ship-finish.js');
158
+ const shipLand = require('../scripts/gds/ship-land.js');
159
+
160
+ await test('publishHeld publishes through the PR path with hold:true, after the same regeneration a land runs', async () => {
161
+ const calls = [];
162
+ const out = await shipMerge.publishHeld(5001, 'sum', {
163
+ currentBranch: () => 'task-5001',
164
+ regenerate: () => calls.push('regen'),
165
+ ciLand: async (id, branch, summary, opts) => { calls.push(['ciLand', id, branch, summary, opts]); return shipLand.landHeld({ prUrl: 'u' }); },
166
+ });
167
+ assert.deepEqual(calls, ['regen', ['ciLand', 5001, 'task-5001', 'sum', { hold: true }]]);
168
+ assert.equal(out.held, true);
169
+ assert.equal(out.ok, false, 'not a land');
170
+ assert.equal(out.pending, false, 'and not "landing automatically"');
171
+ });
172
+
173
+ await test('publishHeld on main has no branch to publish: a named bail, and nothing is pushed', async () => {
174
+ let pushed = false;
175
+ const out = await shipMerge.publishHeld(5001, 'sum', { currentBranch: () => 'main', localTaskBranchExists: () => true, regenerate: () => { pushed = true; }, ciLand: async () => { pushed = true; } });
176
+ assert.equal(pushed, false);
177
+ assert.equal(out.ok, false);
178
+ assert.match(out.reason, /not the task branch/);
179
+ assert.match(out.nextStep, /git checkout task-5001/);
180
+ });
181
+
182
+ await test('finishHeld says the page is WAITING FOR THE ARTIST, syncs the session, and never merges', async () => {
183
+ const printed = [];
184
+ const orig = console.log;
185
+ console.log = (m) => printed.push(String(m));
186
+ let synced = null;
187
+ try {
188
+ await shipFinish.finishHeld({ taskId: 5001, summary: 's', postGradeStatus: 'completed', card: null }, {
189
+ publishHeld: async () => shipLand.landHeld({ prUrl: 'https://github.com/o/r/pull/77' }),
190
+ postShipSync: async (id, status) => { synced = status; return null; },
191
+ printCompletionCard: async () => {},
192
+ });
193
+ } finally { console.log = orig; }
194
+ const text = printed.join('\n');
195
+ assert.match(text, /WAITING FOR THE ARTIST/);
196
+ assert.match(text, /pull\/77/);
197
+ assert.match(text, /auto-merge is NOT armed/);
198
+ assert.equal(synced, 'confirmed', 'the finished session is uploaded like a confirmed one');
199
+ });
200
+
201
+ await test('finishShip routes a held pass away from mergeAndDeploy (the source says so, and only when held)', async () => {
202
+ const fs = require('node:fs');
203
+ const src = fs.readFileSync(require.resolve('../scripts/gds/ship-finish.js'), 'utf8');
204
+ const body = src.slice(src.indexOf('async function finishShip('));
205
+ const holdAt = body.indexOf('if (held) return finishHeld(');
206
+ const mergeAt = body.indexOf('await mergeAndDeploy(');
207
+ assert.ok(holdAt > 0 && mergeAt > holdAt, 'the hold is decided BEFORE any merge is attempted');
208
+ const flow = fs.readFileSync(require.resolve('../scripts/gds/ship-flow.js'), 'utf8');
209
+ assert.equal((flow.match(/held = gradePost\.data\.held === true/g) || []).length, 1, 'shipMain reads the hold off the grade response');
210
+ assert.equal((flow.match(/held: gradePost\.data\.held === true/g) || []).length, 1, 'regradeMain does too');
211
+ assert.match(flow, /tweakHold\.isHeldForArtist\(task, tRes\.data\.grade/, 'a re-run of a held task re-publishes instead of re-grading');
212
+ });
213
+
214
+ // ---- 4. strand-watch -------------------------------------------------------
215
+
216
+ const sw = require('../scripts/gds/strand-watch.js');
217
+ const OLD = new Date(Date.now() - 3 * 86400000).toISOString();
218
+
219
+ function fakeCall({ completed, grades, throwFor = new Set() }) {
220
+ const gradeReads = [];
221
+ const call = async (method, url) => {
222
+ const m = /\/tasks\/(\d+)\?include=grade/.exec(url);
223
+ if (m) {
224
+ gradeReads.push(m[1]);
225
+ if (throwFor.has(m[1])) throw new Error('boom');
226
+ return { ok: true, data: { task: {}, grade: grades[m[1]] ?? null } };
227
+ }
228
+ if (/status=completed/.test(url)) return { ok: true, data: { tasks: completed } };
229
+ return { ok: true, data: { tasks: [] } };
230
+ };
231
+ return { call, gradeReads };
232
+ }
233
+
234
+ await test('strand-watch: a held pass is WAITING, not a strand; a failed page tweak and ordinary work still are', async () => {
235
+ const completed = [
236
+ { id: '11', status: 'completed', source: 'page-tweak', updated_at: OLD },
237
+ { id: '12', status: 'completed', source: 'page-tweak', updated_at: OLD },
238
+ { id: '13', status: 'completed', source: 'planning-session', updated_at: OLD },
239
+ ];
240
+ const f = fakeCall({ completed, grades: { 11: { passed: true }, 12: { passed: false } } });
241
+ const r = await sw.check({ call: f.call });
242
+ assert.deepEqual(r.waiting.map((w) => w.id), ['11']);
243
+ assert.equal(r.waiting[0].shape, 'waiting_for_artist');
244
+ assert.deepEqual(r.strands.map((s) => s.id).sort(), ['12', '13']);
245
+ assert.deepEqual(f.gradeReads.sort(), ['11', '12'], 'grades are read for page tweaks only');
246
+ assert.ok(!(sw.line(r) || '').includes('task 11'), 'the session-start line never names a held pass');
247
+ });
248
+
249
+ await test('strand-watch: an unreadable grade stays LOUD (classified as usual), never explained away as a hold', async () => {
250
+ const f = fakeCall({ completed: [{ id: '11', status: 'completed', source: 'page-tweak', updated_at: OLD }], grades: {}, throwFor: new Set(['11']) });
251
+ const r = await sw.check({ call: f.call });
252
+ assert.deepEqual(r.waiting, []);
253
+ assert.deepEqual(r.strands.map((s) => s.id), ['11']);
254
+ });
255
+
256
+ await test('/grade-recover stops on a held pass instead of recovering it', () => {
257
+ const fs = require('node:fs');
258
+ const p = new URL('../.claude/skills/grade-recover/SKILL.md', import.meta.url);
259
+ if (!fs.existsSync(p)) return;
260
+ const md = fs.readFileSync(p, 'utf8');
261
+ assert.match(md, /waiting for the artist/);
262
+ assert.match(md, /page-tweak/);
263
+ });
264
+
265
+ summary();
@@ -18,6 +18,10 @@ const require = createRequire(import.meta.url);
18
18
 
19
19
  const gp = require('../modules/lifecycle/github-push.js');
20
20
  const buildTasksRouter = require('../modules/lifecycle/routes/tasks.js');
21
+ // task 1004322: the route asks whether the task is a page tweak held for its
22
+ // artist before it nudges a merge. That read goes to the DB, so the harness
23
+ // stubs it (false = an ordinary task, the case every test below was written for).
24
+ const pageTweakReads = require('../modules/lifecycle/page-tweak-reads.js');
21
25
 
22
26
  let pass = 0;
23
27
  let fail = 0;
@@ -33,6 +37,8 @@ const CRED_KEYS = ['GITHUB_PUSH_TOKEN', 'GITHUB_APP_ID', 'GITHUB_APP_INSTALLATIO
33
37
  async function callRoute(query, stubs = {}) {
34
38
  const saved = {};
35
39
  for (const k of ['findTaskPullRequest', 'getPublishStatus', 'mergeIfGreen']) saved[k] = gp[k];
40
+ const savedHold = pageTweakReads.heldForArtistFailClosed;
41
+ pageTweakReads.heldForArtistFailClosed = async () => stubs.held === true;
36
42
  const savedEnv = CRED_KEYS.map((k) => [k, process.env[k]]);
37
43
  const calls = { findTaskPullRequest: [], getPublishStatus: [], mergeIfGreen: [] };
38
44
  gp.findTaskPullRequest = async (a) => { calls.findTaskPullRequest.push(a); return stubs.taskPr === undefined ? null : stubs.taskPr; };
@@ -58,6 +64,7 @@ async function callRoute(query, stubs = {}) {
58
64
  return { ...out, calls };
59
65
  } finally {
60
66
  Object.assign(gp, saved);
67
+ pageTweakReads.heldForArtistFailClosed = savedHold;
61
68
  for (const [k, v] of savedEnv) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
62
69
  }
63
70
  }
@@ -124,5 +131,21 @@ await t('a genuinely unsafe branch is still refused 400 — in a sentence, befor
124
131
  }
125
132
  });
126
133
 
134
+ await t('a page tweak held for its artist is reported but NEVER nudged to merge (task 1004322, ADR 0341 D7)', async () => {
135
+ const r = await callRoute({}, { held: true, taskPr: { branch: 'task-1003747', pr_number: 673, pr_state: 'open', merged: false } });
136
+ assert.equal(r.failCode, null);
137
+ assert.equal(r.body.ok, true, 'the status is still answered: the Approval queue reads it');
138
+ assert.equal(r.body.pr_number, 673);
139
+ assert.deepEqual(r.calls.mergeIfGreen, [], 'a held pass must not be merged by a status poll');
140
+ assert.equal(r.body.merge_driver, null);
141
+ });
142
+
143
+ await t('the hold read failing is treated as held: fail-closed, no nudge (task 1004322)', async () => {
144
+ const real = pageTweakReads.heldForArtistFailClosed;
145
+ assert.equal(await real(1, async () => { throw new Error('db down'); }), true, 'a throwing read answers held');
146
+ assert.equal(await real(1, async () => ({ held: false })), false);
147
+ assert.equal(await real(1, async () => ({ held: true })), true);
148
+ });
149
+
127
150
  console.log(`\npublish-status branch resolution (task 1003764): ${pass} passed, ${fail} failed`);
128
151
  process.exitCode = fail > 0 ? 1 : 0;