@bongos/core 1.23.16 → 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.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/bongos/package-core.js",
5
- "core_version": "1.23.16",
6
- "core_contract": "1.23.16",
7
- "source_commit": "6e661df1836bf2b9f9e4574d98a7fc05b05fb98a",
5
+ "core_version": "1.23.17",
6
+ "core_contract": "1.23.17",
7
+ "source_commit": "4a502e79527cf82113e88b2023d3008dfbae984f",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-28T00:12:52.362Z",
9
+ "built_at": "2026-10-28T00:35:19.416Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 616,
13
13
  "agent_docs_stubbed": 29,
14
- "functional_verbatim": 3584,
14
+ "functional_verbatim": 3587,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 4230,
20
- "tree_sha256": "7ebfe9f3a223ab157c483e0e2bf97c093ff0225711fdbc542c9a572075287574",
19
+ "file_count": 4233,
20
+ "tree_sha256": "99139b31ab3e5e0d6fb9464e6ad1295547d3b7dad361ee272235333df79fc8a2",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2937,7 +2937,7 @@
2937
2937
  {
2938
2938
  "path": "docs/module-api-changelog.md",
2939
2939
  "mode": "0000644",
2940
- "sha256": "31d8127f92c9fc127bf1ba774ad14e116e66ad5b9a7ef2625721fb9f675d5841"
2940
+ "sha256": "4bc6873970f7d2632aea896316a31aaef5a2cc6fd51cfa69f1609974b999cb67"
2941
2941
  },
2942
2942
  {
2943
2943
  "path": "docs/modules-contract.md",
@@ -4484,6 +4484,11 @@
4484
4484
  "mode": "0000644",
4485
4485
  "sha256": "6bf4b2a9b9468ca6a5d077a5e01584c66e2ba3a82af3039ee7b9d4be7bc4dbde"
4486
4486
  },
4487
+ {
4488
+ "path": "modules/copy-desk/auto-close.js",
4489
+ "mode": "0000644",
4490
+ "sha256": "e27d2856bdc6e61116e486c053a353bde07c36ac5d850dce278e5d3c1e4135d0"
4491
+ },
4487
4492
  {
4488
4493
  "path": "modules/copy-desk/docx.js",
4489
4494
  "mode": "0000644",
@@ -4517,7 +4522,7 @@
4517
4522
  {
4518
4523
  "path": "modules/copy-desk/module.json",
4519
4524
  "mode": "0000644",
4520
- "sha256": "0d98abbe640a1fa9326d29ebc44678b0d05e87d6455548f64bcd80c51ea747d1"
4525
+ "sha256": "e5885c9524764cd3e5a1cafc5d352b47e8b1773509854f53c989570c3d02be5d"
4521
4526
  },
4522
4527
  {
4523
4528
  "path": "modules/copy-desk/page-data.js",
@@ -4539,6 +4544,11 @@
4539
4544
  "mode": "0000644",
4540
4545
  "sha256": "595946a1ef88b65612779de0f3dafa0b44b2a02e2b3652f0e026863a9ef4d60c"
4541
4546
  },
4547
+ {
4548
+ "path": "modules/copy-desk/pollers/auto-close-rounds.js",
4549
+ "mode": "0000644",
4550
+ "sha256": "1f370c827350735270704e0e1c7bd5c4427449709c071e3917b1251acfc8dc52"
4551
+ },
4542
4552
  {
4543
4553
  "path": "modules/copy-desk/pollers/stuck-page-digest.js",
4544
4554
  "mode": "0000644",
@@ -4592,7 +4602,12 @@
4592
4602
  {
4593
4603
  "path": "modules/copy-desk/tests/copy_no_cms.mjs",
4594
4604
  "mode": "0000644",
4595
- "sha256": "7d03e68af010a2c2dd8dead6157c3e80643d0ef5956b45359eb1461d224a59e4"
4605
+ "sha256": "3c5401ec7fbf0aa8a255f0a20ce4a25e285b5c7b3ceb90b88ba1434a837f17b6"
4606
+ },
4607
+ {
4608
+ "path": "modules/copy-desk/tests/copy_page_auto_close.mjs",
4609
+ "mode": "0000644",
4610
+ "sha256": "ec7b6b3ec7f974f79abe5ffdcfb08668ec11477599b021303335374b3e7c59ec"
4596
4611
  },
4597
4612
  {
4598
4613
  "path": "modules/copy-desk/tests/copy_page_draft.mjs",
@@ -10942,12 +10957,12 @@
10942
10957
  {
10943
10958
  "path": "package-lock.json",
10944
10959
  "mode": "0000644",
10945
- "sha256": "9812601e19d98dc57f1c6327750d37d3f1ba5e9a2e77fcc7aa5efcafeb0c1889"
10960
+ "sha256": "22b8b7bf76a70bbd3bbb57ed0ab3f53657cf2a989eb6acc8578022311932e5c1"
10946
10961
  },
10947
10962
  {
10948
10963
  "path": "package.json",
10949
10964
  "mode": "0000644",
10950
- "sha256": "8c6f24f0e4d27bb040f8ca7a1a5f8a438638bb94632870110aa18f82662ca9a2"
10965
+ "sha256": "a9218f9cc690dc0c929a5fe7b6a4fccb6a0103f08c1637d09be5c2d23b96b035"
10951
10966
  },
10952
10967
  {
10953
10968
  "path": "public-docs/index.html",
@@ -10967,7 +10982,7 @@
10967
10982
  {
10968
10983
  "path": "release-notes.json",
10969
10984
  "mode": "0000644",
10970
- "sha256": "46ad6e108a8bdf1681a0a79efacf6c5bbe83adbe06bfb6bf554d8bfedf8dffc4"
10985
+ "sha256": "d1193b47af93db407c4fe4ec5a227fdd5c97bd8ebeaa5d1f2c9b0a4c0fab32ca"
10971
10986
  },
10972
10987
  {
10973
10988
  "path": "scripts/bongos/CLAUDE.md",
@@ -14842,7 +14857,7 @@
14842
14857
  {
14843
14858
  "path": "src/module-api.js",
14844
14859
  "mode": "0000644",
14845
- "sha256": "84676e50d010ed084fe27550fc614a050d3d794b2bacadfaed0b6cd9b87b9140"
14860
+ "sha256": "e28e39ed8813e02cf4f2689fdf369d45ea3631a6fb6f2994cfaa0089eb4898c1"
14846
14861
  },
14847
14862
  {
14848
14863
  "path": "src/module-loader/catalog.js",
@@ -3247,5 +3247,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
3247
3247
  landed since 1.23.14 with no explicit bump. run 40604630697. (task 1002620)
3248
3248
  1.23.16 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3249
3249
  landed since 1.23.15 with no explicit bump. run 40607196561. (task 1002620)
3250
+ 1.23.17 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3251
+ landed since 1.23.16 with no explicit bump. run 40609586769. (task 1002620)
3250
3252
  ---------------------------------------------------------------------------
3251
3253
  ```
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ // auto-close.js — a page round with nothing in it closes itself (task 1004899).
4
+ //
5
+ // WHY. A round only moves when a person or a runner acts on it, so a round that
6
+ // holds nothing to act on sat in the Studio as "Needs a look" (or "Submitted,
7
+ // waiting to be applied") forever: someone had to notice it and abandon it by
8
+ // hand. Two shapes hold nothing:
9
+ //
10
+ // blank `ready`, no draft and no frozen batch: opened in the
11
+ // studio, nothing written, the claim dropped.
12
+ // nothing_to_apply `ready` with a frozen batch whose `lines` is empty: every
13
+ // rewrite left the batch for an engineer task at submit
14
+ // (unplaced, shared or removed lines), so /tweak has no line
15
+ // to apply and the round can never move on its own.
16
+ //
17
+ // Both are abandoned through the lifecycle port's releaseStuckTweak (no compose,
18
+ // so the note is the reason and nothing is written to the description). The
19
+ // engineer tasks a submit filed are untouched: they carry the artist's words.
20
+ //
21
+ // `closeReason` is pure; `runAutoClose` is the I/O the poller calls. A round
22
+ // someone holds, or one touched within GRACE_MS, is never closed: an artist who
23
+ // just released a draft to come back to it in a minute keeps it.
24
+
25
+ const api = require('../../src/module-api');
26
+ const pages = require('./pages');
27
+
28
+ const GRACE_MS = Number(process.env.COPY_AUTO_CLOSE_GRACE_MS) || 60 * 60_000;
29
+
30
+ const NOTES = Object.freeze({
31
+ blank: 'Closed by itself: the round was opened but nothing was written in it.',
32
+ nothing_to_apply: 'Closed by itself: every rewritten line went to an engineer task, so nothing was left to apply on the page.',
33
+ });
34
+
35
+ function closeReason(task, { now = Date.now(), graceMs = GRACE_MS } = {}) {
36
+ if (!task || task.status !== 'ready' || task.holder_id != null) return null;
37
+ const touched = Date.parse(task.updated_at || task.created_at || '');
38
+ if (!Number.isFinite(touched) || now - touched < graceMs) return null;
39
+ const p = pages.blockPresence(task.description);
40
+ if (!p.batch) return p.draft ? null : 'blank';
41
+ const b = pages.parseBatchBlock(task.description);
42
+ if (b.ok && b.batch.lines.length === 0) return 'nothing_to_apply';
43
+ return null;
44
+ }
45
+
46
+ // One sweep. Returns what happened per round, so the poller and the tests read
47
+ // the same outcome. The actor is the project owner (a system act, recorded on
48
+ // the task as its abandon reason); no owner, no sweep.
49
+ async function runAutoClose({ now = Date.now(), resolve = (k) => (api.resolveOptional ? api.resolveOptional(k) : null), founder = () => api.getFoundingBuilder() } = {}) {
50
+ const lifecycle = resolve('lifecycle');
51
+ if (!lifecycle || typeof lifecycle.listPageTweakTasks !== 'function' || typeof lifecycle.releaseStuckTweak !== 'function') {
52
+ return { closed: [], skipped: 'no_lifecycle' };
53
+ }
54
+ const tasks = await lifecycle.listPageTweakTasks();
55
+ const due = tasks.map((t) => ({ t, why: closeReason(t, { now }) })).filter((x) => x.why);
56
+ if (!due.length) return { closed: [] };
57
+ const owner = await founder();
58
+ if (!owner) return { closed: [], skipped: 'no_owner' };
59
+ const closed = [];
60
+ for (const { t, why } of due) {
61
+ const r = await lifecycle.releaseStuckTweak({ taskId: t.id, builderId: owner.id, expectedStatus: 'ready', note: NOTES[why] });
62
+ closed.push({ task_id: String(t.id), why, outcome: r.outcome });
63
+ }
64
+ return { closed };
65
+ }
66
+
67
+ module.exports = { closeReason, runAutoClose };
@@ -7,7 +7,7 @@
7
7
  "category": { "parent": "quality" },
8
8
  "default": true,
9
9
  "maintenance": { "status": "core-maintained" },
10
- "contributes": { "routes": ["copy-desk", "copy-desk-artist", "copy-desk-release"], "migrations": true, "skills": ["tweak"], "pollers": ["stuck-page-digest"] },
10
+ "contributes": { "routes": ["copy-desk", "copy-desk-artist", "copy-desk-release"], "migrations": true, "skills": ["tweak"], "pollers": ["stuck-page-digest", "auto-close-rounds"] },
11
11
  "provides": ["copy-desk.artistStats", "copy-desk.stuckPageTweaksFor"],
12
12
  "consumes": ["lifecycle"]
13
13
  }
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ // modules/copy-desk/pollers/auto-close-rounds.js — closes page rounds that hold
4
+ // nothing to act on (task 1004899). Loader-started (contributes.pollers); the
5
+ // rule lives in ../auto-close.js. Every 15 minutes by default
6
+ // (COPY_AUTO_CLOSE_MS); COPY_AUTO_CLOSE_DISABLED=1 turns it off. Best-effort: a
7
+ // failed sweep logs and tries again next interval.
8
+
9
+ const api = require('../../../src/module-api');
10
+ const autoClose = require('../auto-close');
11
+
12
+ const log = api.logger('copy-desk');
13
+ const INTERVAL_MS = Number(process.env.COPY_AUTO_CLOSE_MS) || 15 * 60_000;
14
+ let timer = null;
15
+
16
+ function tick() {
17
+ autoClose.runAutoClose()
18
+ .then((r) => { if (r.closed.length) log.info(`[auto-close-rounds] closed ${r.closed.length} empty page round(s)`); })
19
+ .catch((e) => log.error(e, '[auto-close-rounds] sweep failed'));
20
+ }
21
+
22
+ module.exports = {
23
+ start() {
24
+ if (process.env.COPY_AUTO_CLOSE_DISABLED === '1' || timer) return;
25
+ timer = setInterval(tick, INTERVAL_MS);
26
+ if (timer.unref) timer.unref();
27
+ },
28
+ stop() {
29
+ if (timer) clearInterval(timer);
30
+ timer = null;
31
+ },
32
+ };
@@ -70,7 +70,7 @@ const stripComments = (src) => src
70
70
  // Task 1004435 added routes/copy-desk-artist.js (one artist's approved pages, a
71
71
  // read) and artist-stats.js's readArtistPages under it.
72
72
  const ROUTE_FILES = fs.readdirSync(path.join(MODULE_DIR, 'routes')).filter((f) => f.endsWith('.js')).sort().map((f) => `routes/${f}`);
73
- const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'page-status.js', 'page-data.js', 'docx.js', 'page-decisions.js', 'artist-stats.js', ...ROUTE_FILES];
73
+ const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'page-status.js', 'page-data.js', 'docx.js', 'page-decisions.js', 'artist-stats.js', 'auto-close.js', ...ROUTE_FILES];
74
74
 
75
75
  console.log('copy-desk: NO LIVE CMS');
76
76
 
@@ -310,6 +310,10 @@ await test('the module manifest declares no seam another module could write copy
310
310
  assert.deepEqual(manifest.provides, ['copy-desk.artistStats', 'copy-desk.stuckPageTweaksFor'], 'a provided port here would be a copy write path for every other module');
311
311
  const stuckSrc = stripComments(read('stuck-tweaks.js'));
312
312
  assert.equal(/writeFile|\.query\(|UPDATE |INSERT /.test(stuckSrc), false, 'the stuck-tweaks port writes nothing');
313
+ // task 1004899: the auto-close sweep only ABANDONS an empty round through the
314
+ // lifecycle port; it never hands a compose, so it cannot write a description.
315
+ const closeSrc = stripComments(read('auto-close.js'));
316
+ assert.equal(/writeFile|\.query\(|UPDATE |INSERT |compose/.test(closeSrc), false, 'the auto-close sweep abandons and writes nothing');
313
317
  const stats = read('artist-stats.js');
314
318
  assert.equal(/\b(pool|query|writeFile|appendFile|INSERT|UPDATE|DELETE)\b/.test(stats.replace(/\/\/.*$/gm, '')), false,
315
319
  'the artist-stats port reads through other ports and writes nothing');
@@ -0,0 +1,92 @@
1
+ // modules/copy-desk/tests/copy_page_auto_close.mjs — a page round with nothing in
2
+ // it closes itself (task 1004899).
3
+ //
4
+ // The done-when: a blank round and a round whose batch has no lines left are
5
+ // abandoned by the sweep; a round with real lines, a draft, a held round, a
6
+ // round waiting for its artist and a freshly touched one are never touched.
7
+
8
+ import { strict as assert } from 'node:assert';
9
+ import { createRequire } from 'node:module';
10
+
11
+ const require = createRequire(import.meta.url);
12
+ const pages = require('../pages.js');
13
+ const autoClose = require('../auto-close.js');
14
+
15
+ let passed = 0;
16
+ let failed = 0;
17
+ async function test(name, fn) {
18
+ try { await fn(); passed++; console.log(` ok ${name}`); }
19
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
20
+ }
21
+
22
+ const NOW = Date.parse('2026-10-27T12:00:00.000Z');
23
+ const OLD = '2026-10-20T12:00:00.000Z';
24
+ const PAGE = 'builders:collab';
25
+
26
+ const line = { key: 'L0001', section: 'Top', before: 'Archive', after: 'Past Collabs', placement: 'placed' };
27
+ const draft = pages.composeDraftBlock({ page_id: PAGE, reading_hash: 'h', saved_at: OLD, lines: [line] });
28
+ const batch = (lines) => pages.composeBatchBlock({ page_id: PAGE, reading_hash: 'h', submitted_at: OLD, lines, unplaced_tasks: lines.length ? [] : ['1004692'] });
29
+
30
+ const round = (over) => ({ id: '1', status: 'ready', description: 'Page tweak round 1.', holder_id: null, updated_at: OLD, created_at: OLD, ...over });
31
+
32
+ const ROUNDS = {
33
+ blank: round({ id: '11' }),
34
+ empty_batch: round({ id: '12', description: `${draft}\n\n${batch([])}` }),
35
+ real_batch: round({ id: '13', description: `${draft}\n\n${batch([line])}` }),
36
+ draft_only: round({ id: '14', description: draft }),
37
+ held: round({ id: '15', status: 'active', holder_id: '7' }),
38
+ waiting: round({ id: '16', status: 'completed', description: `${draft}\n\n${batch([line])}` }),
39
+ fresh_blank: round({ id: '17', updated_at: new Date(NOW - 5 * 60_000).toISOString() }),
40
+ ready_but_held: round({ id: '18', holder_id: '7' }),
41
+ };
42
+
43
+ console.log('copy-desk: empty page rounds close themselves');
44
+
45
+ await test('a blank round and an empty batch are the only two that close', () => {
46
+ const got = Object.fromEntries(Object.entries(ROUNDS).map(([k, t]) => [k, autoClose.closeReason(t, { now: NOW })]));
47
+ assert.deepEqual(got, {
48
+ blank: 'blank',
49
+ empty_batch: 'nothing_to_apply',
50
+ real_batch: null,
51
+ draft_only: null,
52
+ held: null,
53
+ waiting: null,
54
+ fresh_blank: null,
55
+ ready_but_held: null,
56
+ });
57
+ });
58
+
59
+ await test('the grace period is honoured: a round touched within it stays', () => {
60
+ const t = round({ updated_at: new Date(NOW - 59 * 60_000).toISOString() });
61
+ assert.equal(autoClose.closeReason(t, { now: NOW, graceMs: 60 * 60_000 }), null);
62
+ assert.equal(autoClose.closeReason(t, { now: NOW, graceMs: 30 * 60_000 }), 'blank');
63
+ });
64
+
65
+ await test('the sweep abandons through the lifecycle port, as the owner, with a plain note and no rewrite', async () => {
66
+ const calls = [];
67
+ const lifecycle = {
68
+ listPageTweakTasks: async () => Object.values(ROUNDS),
69
+ releaseStuckTweak: async (args) => { calls.push(args); return { outcome: 'abandoned' }; },
70
+ };
71
+ const r = await autoClose.runAutoClose({ now: NOW, resolve: () => lifecycle, founder: async () => ({ id: '1' }) });
72
+ assert.deepEqual(r.closed.map((c) => [c.task_id, c.why, c.outcome]), [['11', 'blank', 'abandoned'], ['12', 'nothing_to_apply', 'abandoned']]);
73
+ for (const c of calls) {
74
+ assert.equal(c.builderId, '1');
75
+ assert.equal(c.expectedStatus, 'ready');
76
+ assert.equal('compose' in c, false, 'no compose: the round is abandoned, never rewritten');
77
+ assert.ok(c.note.length >= pages.MIN_NOTE_CHARS && c.note.length <= pages.MAX_NOTE_CHARS, 'the note fits the release note floor and cap');
78
+ }
79
+ });
80
+
81
+ await test('nothing due, no owner read; no owner, no sweep; no lifecycle, no sweep', async () => {
82
+ let asked = 0;
83
+ const quiet = { listPageTweakTasks: async () => [ROUNDS.real_batch], releaseStuckTweak: async () => { throw new Error('must not run'); } };
84
+ assert.deepEqual(await autoClose.runAutoClose({ now: NOW, resolve: () => quiet, founder: async () => { asked++; return { id: '1' }; } }), { closed: [] });
85
+ assert.equal(asked, 0);
86
+ const due = { listPageTweakTasks: async () => [ROUNDS.blank], releaseStuckTweak: async () => { throw new Error('must not run'); } };
87
+ assert.equal((await autoClose.runAutoClose({ now: NOW, resolve: () => due, founder: async () => null })).skipped, 'no_owner');
88
+ assert.equal((await autoClose.runAutoClose({ now: NOW, resolve: () => null })).skipped, 'no_lifecycle');
89
+ });
90
+
91
+ console.log(`\n${passed} passed, ${failed} failed`);
92
+ process.exit(failed ? 1 : 0);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.23.16",
3
+ "version": "1.23.17",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.23.16",
9
+ "version": "1.23.17",
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.23.16",
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",
@@ -10485,5 +10485,11 @@
10485
10485
  "id": "1004900",
10486
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
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
+ }
10488
10494
  ]
10489
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.16'; // 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');