@bongos/core 1.23.15 → 1.23.17

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 (33) hide show
  1. package/.bongos-core.json +51 -36
  2. package/docs/copy-inventory.md +8 -8
  3. package/docs/copy-registry.json +26 -26
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-readings.json +3 -3
  6. package/modules/copy-desk/auto-close.js +67 -0
  7. package/modules/copy-desk/module.json +1 -1
  8. package/modules/copy-desk/page-decisions.js +32 -4
  9. package/modules/copy-desk/page-status.js +19 -10
  10. package/modules/copy-desk/pages.js +61 -0
  11. package/modules/copy-desk/pollers/auto-close-rounds.js +32 -0
  12. package/modules/copy-desk/routes/copy-desk.js +7 -3
  13. package/modules/copy-desk/skills/tweak/SKILL.md +1 -1
  14. package/modules/copy-desk/tests/copy_no_cms.mjs +5 -1
  15. package/modules/copy-desk/tests/copy_page_auto_close.mjs +92 -0
  16. package/modules/copy-desk/tests/copy_page_next.mjs +1 -1
  17. package/modules/copy-desk/tests/copy_page_status.mjs +9 -4
  18. package/modules/hall-ui/public/approval-queue-lib.js +10 -3
  19. package/modules/hall-ui/public/approval-queue.js +7 -5
  20. package/modules/hall-ui/public/studio-lib.js +3 -2
  21. package/modules/lifecycle/lifecycle.js +2 -0
  22. package/modules/lifecycle/page-tweak-approve.js +50 -2
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/release-notes.json +12 -0
  26. package/src/module-api.js +1 -1
  27. package/tests/copy_desk_page_approve.mjs +53 -0
  28. package/tests/copy_desk_page_editor.mjs +2 -2
  29. package/tests/copy_desk_page_next.mjs +1 -1
  30. package/tests/copy_desk_page_release.mjs +1 -1
  31. package/tests/copy_desk_page_submit.mjs +2 -2
  32. package/tests/copy_stuck_page_tweaks.mjs +12 -3
  33. package/tests/hall_approval_queue.mjs +6 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.23.15",
3
+ "version": "1.23.17",
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",
@@ -10479,5 +10479,17 @@
10479
10479
  "id": "1002981",
10480
10480
  "text": "The community pages now have a Builder of the week: each Monday the builder who earned the most in the week before, among those who shipped something, is crowned and kept on record. It has its own page, with the rule spelled o"
10481
10481
  }
10482
+ ],
10483
+ "1.23.16": [
10484
+ {
10485
+ "id": "1004900",
10486
+ "text": "An artist's rewritten page words are now approved in the Studio before anything changes on the page. Only approved words go on to be applied, and words sent back become a draft the artist can change again."
10487
+ }
10488
+ ],
10489
+ "1.23.17": [
10490
+ {
10491
+ "id": "1004899",
10492
+ "text": "Page tweak rounds that hold nothing to do now close themselves after an hour, so the Studio no longer shows empty rounds as needing a look forever."
10493
+ }
10482
10494
  ]
10483
10495
  }
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.23.15'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.23.17'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -288,6 +288,59 @@ try {
288
288
  assert.equal(pages.parseSentBackBlocks(task(5002).description).length, 2, 'send-backs append');
289
289
  });
290
290
 
291
+ // task 1004900: the WORDS of a submitted batch are reviewed before anything is
292
+ // applied. Approve puts the round in the /tweak queue; send-back withdraws the
293
+ // batch so the round is a draft its artist can change.
294
+ await test('words first: a submitted batch is in the queue as words, not applied, and only an approval sends it to /tweak', async () => {
295
+ const STUDIO = '/copy-desk/pages/builders:studio';
296
+ const line = { key: 'L0001', section: 'top', before: 'Welcome home', after: 'Welcome back', placement: 'placed', file: 'x.html', line: 1, string_id: 'abc' };
297
+ const desc = () => [
298
+ 'Page tweak round 1 for builders:studio.',
299
+ pages.composeDraftBlock({ page_id: 'builders:studio', reading_hash: 'h', saved_at: '2026-09-23T00:00:00Z', lines: [line] }),
300
+ pages.composeBatchBlock({ page_id: 'builders:studio', reading_hash: 'h', submitted_at: '2026-09-23T00:00:00Z', lines: [line] }),
301
+ ].join('\n\n');
302
+ db.tasks.push({ id: '7001', status: 'ready', title: 'Tweak: Studio', description: desc(), source: 'page-tweak', source_ref: pages.roundSourceRef('builders:studio', 1), touches: [], version_id: 'V9', goal_id: '77' });
303
+ db.claims.push({ id: String(++db.seq.claim), task_id: '7001', builder_id: ALICE, worktree_name: null, creator_session_id: null, claimed_at: stamp(), released_at: stamp(), outcome: 'partial' });
304
+
305
+ assert.equal((await call('GET', STUDIO, ALICE)).body.open_round.state, 'words_review');
306
+ const q = (await call('GET', '/copy-desk/approvals', BOB)).body;
307
+ const item = q.queue.find((x) => x.task_id === '7001');
308
+ assert.ok(item, 'the submitted words are in the queue');
309
+ assert.equal(item.kind, 'words');
310
+ assert.equal(item.applied_at, null);
311
+ assert.deepEqual(item.lines.map((l) => [l.before, l.after]), [['Welcome home', 'Welcome back']]);
312
+ assert.equal(q.not_applied.some((r) => r.task_id === '7001'), false, 'not listed as approved-and-waiting');
313
+
314
+ const dan = await call('POST', `${STUDIO}/approve`, DAN, { task_id: '7001' });
315
+ assert.equal(errCode(dan.body), 'not_an_artist');
316
+ const short = await call('POST', `${STUDIO}/send-back`, BOB, { note: 'nope' });
317
+ assert.equal(errCode(short.body), 'note_too_short');
318
+
319
+ const ok = await call('POST', `${STUDIO}/approve`, BOB, { task_id: '7001' });
320
+ assert.equal(ok.status, 200, JSON.stringify(ok.body));
321
+ assert.equal(ok.body.outcome, 'words_approved');
322
+ assert.deepEqual(ok.body.round, { task_id: '7001', state: 'submitted', status: 'ready' });
323
+ assert.equal(task(7001).status, 'ready', 'nothing applied or landed: the task only gained the approval');
324
+ assert.deepEqual(pages.parseWordsApprovedBlocks(task(7001).description).map((a) => a.by), [BOB]);
325
+ assert.equal((await call('GET', STUDIO, ALICE)).body.open_round.state, 'submitted', 'now in the /tweak queue');
326
+ const after = (await call('GET', '/copy-desk/approvals', BOB)).body;
327
+ assert.equal(after.queue.some((x) => x.task_id === '7001'), false);
328
+ assert.equal(after.not_applied.some((r) => r.task_id === '7001'), true, 'listed as approved, waiting to be applied');
329
+ const twice = await call('POST', `${STUDIO}/approve`, BOB);
330
+ assert.equal(errCode(twice.body), 'not_waiting', 'an approved batch is not decided again');
331
+
332
+ // A second round of words, sent back: the batch goes, the draft stays, the note rests on the task.
333
+ task(7001).description = desc();
334
+ const back = await call('POST', `${STUDIO}/send-back`, ALICE, { note: 'I want to say this differently.', task_id: '7001' });
335
+ assert.equal(back.status, 200, JSON.stringify(back.body));
336
+ assert.equal(back.body.outcome, 'words_sent_back');
337
+ assert.equal(pages.parseBatchBlock(task(7001).description).ok, false, 'the batch is withdrawn');
338
+ assert.equal(pages.parseDraftBlock(task(7001).description).ok, true, 'the draft stays for its writer');
339
+ assert.deepEqual(pages.parseSentBackBlocks(task(7001).description).map((s) => s.note), ['I want to say this differently.']);
340
+ assert.equal((await call('GET', STUDIO, ALICE)).body.open_round.state, 'draft', 'a draft again, open for writing');
341
+ assert.equal(pages.roundOpenForWriting(task(7001)), true);
342
+ });
343
+
291
344
  await test('an ordinary task and a failed grade are never moved through the port', async () => {
292
345
  db.tasks.push({ id: '6001', status: 'completed', title: 'x', description: '', source: 'planning-session', source_ref: 'x', touches: [] });
293
346
  db.grades.set('6001', true);
@@ -144,10 +144,10 @@ try {
144
144
  writeReadings(readingsWith('studio-h1'));
145
145
  });
146
146
 
147
- await test('after submit the round reads submitted and nobody holds it', async () => {
147
+ await test('after submit the round waits for its words to be reviewed and nobody holds it', async () => {
148
148
  assert.equal((await call('POST', `${STUDIO}/submit`, ALICE)).body.outcome, 'submitted');
149
149
  const r = await call('GET', `${STUDIO}/draft`, ALICE);
150
- assert.equal(r.body.round.state, 'submitted');
150
+ assert.equal(r.body.round.state, 'words_review', 'task 1004900: reviewed before it is applied');
151
151
  assert.equal(r.body.round.held_by_you, false);
152
152
  assert.equal(r.body.round.holder, null);
153
153
  });
@@ -154,7 +154,7 @@ try {
154
154
  round.status = 'ready';
155
155
  round.description += '\n\n' + pages.composeBatchBlock({ page_id: 'status:explore', reading_hash: 'h', submitted_at: 'x', lines: [{ key: 'L0001', section: 'hero', before: 'Explore', after: 'Look around', placement: 'placed', file: 'x', line: 1, string_id: 'a' }] });
156
156
  const next = (await getNext(ALICE)).body;
157
- assert.deepEqual(next.skipped, [{ page_id: 'status:explore', reason: 'page_in_flight', state: 'submitted' }]);
157
+ assert.deepEqual(next.skipped, [{ page_id: 'status:explore', reason: 'page_in_flight', state: 'words_review' }]);
158
158
  const refused = await call('POST', '/copy-desk/pages/status:explore/claim', ALICE, {});
159
159
  assert.equal(errCode(refused.body), 'page_in_flight');
160
160
  });
@@ -104,7 +104,7 @@ try {
104
104
  const flight = Object.fromEntries(r.body.in_flight.map((f) => [f.page_id, f]));
105
105
  assert.deepEqual([flight['landing:index'].state, flight['landing:index'].stuck, flight['landing:index'].task_id], ['unknown', true, '7001']);
106
106
  assert.deepEqual([flight['landing:projects'].state, flight['landing:projects'].stuck], ['unknown', true]);
107
- assert.equal(flight['status:explore'].state, 'submitted');
107
+ assert.equal(flight['status:explore'].state, 'words_review');
108
108
  assert.equal(flight['status:explore'].stuck, false);
109
109
  });
110
110
 
@@ -188,7 +188,7 @@ try {
188
188
  const r = await call('POST', `${STUDIO}/submit`, ALICE);
189
189
  assert.equal(r.status, 200, JSON.stringify(r.body));
190
190
  assert.equal(r.body.outcome, 'submitted');
191
- assert.deepEqual(r.body.round, { task_id: studioTask().id, state: 'submitted', status: 'ready' });
191
+ assert.deepEqual(r.body.round, { task_id: studioTask().id, state: 'words_review', status: 'ready' });
192
192
  assert.equal(r.body.batch.line_count, 2);
193
193
  assert.deepEqual(r.body.next, { path: '/copy-desk/next' });
194
194
 
@@ -229,7 +229,7 @@ try {
229
229
  await test('the page reads submitted, and nobody can claim it back into writing', async () => {
230
230
  const page = await call('GET', STUDIO, BOB);
231
231
  assert.equal(page.status, 200);
232
- assert.equal(page.body.open_round.state, 'submitted');
232
+ assert.equal(page.body.open_round.state, 'words_review');
233
233
  const claim = await call('POST', `${STUDIO}/claim`, BOB);
234
234
  assert.equal(errCode(claim.body), 'page_in_flight');
235
235
  });
@@ -23,7 +23,8 @@ const ago = (d) => new Date(NOW.getTime() - d * 86400000).toISOString();
23
23
  const pg = (id, title = id) => ({ id, surface: id.split(':')[0], title, path: '/', files: [] });
24
24
  const PAGES = [pg('landing:index', '{{productName}}: {{tagline}}'), pg('landing:account', 'Account'), pg('builders:studio', 'Studio'), pg('landing:projects', 'Projects')];
25
25
  const INVENTORY = { surfaces: [{ id: 'landing' }, { id: 'builders' }], pages: PAGES };
26
- const batch = (id) => pages.composeBatchBlock({ page_id: id, reading_hash: 'h', submitted_at: ago(9), lines: [] });
26
+ // Approved words (task 1004900): the round waits to be applied.
27
+ const batch = (id) => `${pages.composeBatchBlock({ page_id: id, reading_hash: 'h', submitted_at: ago(9), lines: [] })}\n\n${pages.composeWordsApprovedBlock({ at: ago(9), by: '7' })}`;
27
28
  const round = (n, id, extra) => ({ id: String(n), source: 'page-tweak', source_ref: pages.roundSourceRef(id, 1), description: pages.roundTaskBody(pg(id), 1), artist_id: '7', ...extra });
28
29
  const ARTIST = '7';
29
30
  const TASKS = [
@@ -45,6 +46,14 @@ await test('the composer: only stuck pages, each with where it sits, how long, a
45
46
  ]);
46
47
  });
47
48
 
49
+ await test('submitted words left unreviewed wait on their artist, not the appliers (task 1004900)', () => {
50
+ const words = round(9, 'landing:projects', { status: 'ready', description: `${pages.roundTaskBody(pg('landing:projects'), 1)}\n\n${pages.composeBatchBlock({ page_id: 'landing:projects', reading_hash: 'h', submitted_at: ago(4), lines: [] })}`, updated_at: ago(4) });
51
+ const got = status.stuckPageTweaks({ inventory: INVENTORY, tasks: [words], now: NOW });
52
+ assert.deepEqual(got.map((s) => [s.page_id, s.state, s.waits_on]), [['landing:projects', 'words_review', 'artist']]);
53
+ assert.deepEqual(status.stuckPageTweaksFor({ builderId: ARTIST, rank: 'xenos', stuck: got }).map((s) => s.page_id), ['landing:projects']);
54
+ assert.deepEqual(status.stuckPageTweaksFor({ builderId: '99', rank: 'metic', stuck: got }), [], 'an applier is not asked to review words');
55
+ });
56
+
48
57
  await test('Home: the artist sees their own review; a Metic+ builder sees what waits to be applied', () => {
49
58
  const forArtist = status.stuckPageTweaksFor({ builderId: ARTIST, rank: 'thetes', stuck: stuck() });
50
59
  assert.deepEqual(forArtist.map((s) => s.page_id), ['landing:account']);
@@ -57,7 +66,7 @@ await test('the real needs roster: one Needs-attention line per page, where and
57
66
  const r = needs.computeNeeds({ rank: 'archon', ownKeySet: true, stuckPageTweaks: status.stuckPageTweaksFor({ builderId: ARTIST, rank: 'archon', stuck: stuck() }) });
58
67
  const lines = r.items.filter((n) => n.id.startsWith('page_tweak_stuck:'));
59
68
  assert.deepEqual(lines.map((n) => [n.id, n.title, n.message, n.state, n.action.href]), [
60
- ['page_tweak_stuck:landing:index', 'Page tweak stuck: landing:index', 'Submitted, waiting to be applied for 26 days.', 'action_needed', '/studio'],
69
+ ['page_tweak_stuck:landing:index', 'Page tweak stuck: landing:index', 'Approved, waiting to be applied for 26 days.', 'action_needed', '/studio'],
61
70
  ['page_tweak_stuck:landing:account', 'Page tweak stuck: Account', 'Waiting for your review for 5 days.', 'action_needed', '/studio'],
62
71
  ]);
63
72
  });
@@ -82,7 +91,7 @@ await test('the real digest: page names only, never copy, to the ship channel, a
82
91
  assert.equal(posts[0].channel, 'ship_feed');
83
92
  assert.equal(posts[0].text, [
84
93
  'Page tweaks stuck (2). Open the Studio to move them:',
85
- '- landing:index: submitted, waiting to be applied, 26 days',
94
+ '- landing:index: approved, waiting to be applied, 26 days',
86
95
  '- Account: waiting for your review, 5 days',
87
96
  ].join('\n'));
88
97
  const again = await stuckTweaks.runDigest({ nowMs: NOW.getTime() + 3600_000, state, post, readStuckImpl });
@@ -141,8 +141,8 @@ await test('lib: the artboard\'s words, from the route\'s own shape', () => {
141
141
  assert.equal(lib.displayTitle(home), 'Home', 'an apex template title is Home, the artboard\'s own word');
142
142
  assert.equal(lib.subLine(home), '· landing · 1 line rewritten, applied · 1 not applied');
143
143
  assert.equal(lib.subLine(projects), '· landing · 1 line rewritten, applied');
144
- assert.equal(lib.doorDetail(2), '2 pages applied, waiting for you');
145
- assert.equal(lib.doorDetail(1), '1 page applied, waiting for you');
144
+ assert.equal(lib.doorDetail(2), '2 pages waiting for you');
145
+ assert.equal(lib.doorDetail(1), '1 page waiting for you');
146
146
  assert.equal(lib.doorDetail(0), 'nothing waiting for you');
147
147
  assert.equal(lib.approvedLine(home, { status_after: 'Text tweaked 3x', next: { page: { title: 'Contact' } } }),
148
148
  'Approved. It ships now, and Home reads text tweaked 3x. Next in the queue: Contact.', 'the artboard line, word for word');
@@ -346,22 +346,22 @@ await test('submitted but not applied: listed apart, marked, how long, no verbs,
346
346
  const submitted = {
347
347
  id: 60, status: 'ready', holder_id: null, artist_id: ME, artist_login: 'me', updated_at: '2026-09-29T08:00:00Z',
348
348
  source_ref: pages.roundSourceRef('landing:projects', 2),
349
- description: ['Page tweak round 2.', pages.composeBatchBlock({ page_id: 'landing:projects', reading_hash: 'h', submitted_at: '2026-09-29T08:00:00Z', lines: [{ key: 'L0002', section: 'list', before: 'Every project', after: 'All of them', placement: 'placed', file: 'x.html', line: 1, string_id: 'a' }] })].join('\n\n'),
349
+ description: ['Page tweak round 2.', pages.composeBatchBlock({ page_id: 'landing:projects', reading_hash: 'h', submitted_at: '2026-09-29T08:00:00Z', lines: [{ key: 'L0002', section: 'list', before: 'Every project', after: 'All of them', placement: 'placed', file: 'x.html', line: 1, string_id: 'a' }] }), pages.composeWordsApprovedBlock({ at: '2026-09-29T09:00:00Z', by: ME })].join('\n\n'),
350
350
  };
351
351
  const tasks = [...TASKS.filter((t) => t.id !== 42), submitted];
352
352
  const data = pageStatus.composeApprovals({ inventory: INVENTORY, tasks, builderId: ME, crafts: ['artist'], now: NOW });
353
353
  assert.equal(data.count, 1, 'the door counts reviewable rounds only');
354
354
  assert.deepEqual(data.queue.map((q) => q.task_id), ['41']);
355
355
  assert.deepEqual(data.not_applied.map((r) => [r.page_id, r.task_id, r.state_word, r.waited_days, r.line_count]),
356
- [['landing:projects', '60', 'Submitted, waiting to be applied', 21, 1]]);
356
+ [['landing:projects', '60', 'Approved, waiting to be applied', 21, 1]]);
357
357
 
358
358
  const b = bootPanel({ answers: { [APPROVALS]: { ok: true, ...data }, [vis(41)]: slotsFor(41) } });
359
359
  await b.panel.open(); await settle(); await settle();
360
- assert.equal(text(b.q('.aq-pending__h')), 'Submitted, not applied yet (1)');
360
+ assert.equal(text(b.q('.aq-pending__h')), 'Approved, not applied yet (1)');
361
361
  const row = b.q('.aq-pending__row');
362
362
  assert.match(text(row), /Projects/);
363
363
  assert.match(text(row), /not applied yet/);
364
- assert.match(text(row), /Submitted, waiting to be applied · waiting 21 days\. The words are waiting to be applied/);
364
+ assert.match(text(row), /Approved, waiting to be applied · waiting 21 days\. The words are approved and waiting to be put on the page/);
365
365
  assert.equal(row.querySelectorAll('button').length, 0, 'no approve or send-back on a page nobody applied');
366
366
  assert.deepEqual(b.counts, [1]);
367
367