@bongos/core 1.23.16 → 1.23.18

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.18",
6
+ "core_contract": "1.23.18",
7
+ "source_commit": "cf436c265cc3be4912f3c0ee9500d8a6fc8e2e20",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-28T00:12:52.362Z",
9
+ "built_at": "2026-10-28T00:49:26.518Z",
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": "eba7a9ced17fd552ba4b96d63bed4bf6de2b1b6b5b8825168fc83c764da4724a",
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": "5231965cae7f22d595883889e831987e6f14ad158155df16fb439918dc6e44fc"
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": "f9f4e8e009fab44c47fd3651c5d44da94cec1fd4cb7ad0931515fadbd6526280"
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",
@@ -4532,13 +4537,18 @@
4532
4537
  {
4533
4538
  "path": "modules/copy-desk/page-status.js",
4534
4539
  "mode": "0000644",
4535
- "sha256": "de12a5dd005cb8ab8f078cc2295af5fb220f09e0ca21192719698ab9037b57be"
4540
+ "sha256": "0d3ae54e54203d62a6b8b7874706a478476d2c22f680ed92dc6d91d2c8285c3e"
4536
4541
  },
4537
4542
  {
4538
4543
  "path": "modules/copy-desk/pages.js",
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": "f992174839c4f2be3c3a6f942c27493a5a7081e46ce01293f9075ce9a26330be"
4596
4611
  },
4597
4612
  {
4598
4613
  "path": "modules/copy-desk/tests/copy_page_draft.mjs",
@@ -4607,7 +4622,7 @@
4607
4622
  {
4608
4623
  "path": "modules/copy-desk/tests/copy_page_status.mjs",
4609
4624
  "mode": "0000644",
4610
- "sha256": "f968af0d0f0bd8d1202816ae1d660c9bd75f706997167a75c2a09c8a9d8ed579"
4625
+ "sha256": "e90260a6fc9b95c65efd1aa1488c88f7344d94e6d315055a52648d4c92f416dc"
4611
4626
  },
4612
4627
  {
4613
4628
  "path": "modules/copy-desk/tests/copy_proposals.mjs",
@@ -10942,12 +10957,12 @@
10942
10957
  {
10943
10958
  "path": "package-lock.json",
10944
10959
  "mode": "0000644",
10945
- "sha256": "9812601e19d98dc57f1c6327750d37d3f1ba5e9a2e77fcc7aa5efcafeb0c1889"
10960
+ "sha256": "f2aa7d637ca9724249d7da6c5522c3c610d8f8f577ad45800c87bc5c34e85400"
10946
10961
  },
10947
10962
  {
10948
10963
  "path": "package.json",
10949
10964
  "mode": "0000644",
10950
- "sha256": "8c6f24f0e4d27bb040f8ca7a1a5f8a438638bb94632870110aa18f82662ca9a2"
10965
+ "sha256": "3e4721211b08ace73fce53f51c9f456fc53915ba54df2816c8e9e4c34df20f87"
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": "48cc837f2857d4db190398862cce83da8cf092ac339e77d043c9b998993ba957"
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": "a69374b5230240bb5f2551554f6ce7e1ac40163e1ddd9ab6bcc5027619734e67"
14846
14861
  },
14847
14862
  {
14848
14863
  "path": "src/module-loader/catalog.js",
@@ -15892,7 +15907,7 @@
15892
15907
  {
15893
15908
  "path": "tests/copy_stuck_page_tweaks.mjs",
15894
15909
  "mode": "0000644",
15895
- "sha256": "d0a860520d482be8014a036d183e570f789195597891c367c16ee7e817825e2a"
15910
+ "sha256": "e53e4b7c768b0e1b5cb2343d174eba8886586678cc3d3a70560d107faa88a1f5"
15896
15911
  },
15897
15912
  {
15898
15913
  "path": "tests/core_258_box_session_claims_db.mjs",
@@ -3247,5 +3247,9 @@ 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)
3252
+ 1.23.18 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3253
+ landed since 1.23.17 with no explicit bump. run 40611101597. (task 1002620)
3250
3254
  ---------------------------------------------------------------------------
3251
3255
  ```
@@ -0,0 +1,73 @@
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
+ // draft_expired `ready` with a draft and no batch, untouched for
17
+ // page-status's DRAFT_EXPIRES_AFTER (14 days, task 1004901):
18
+ // its artist was reminded at 7 days and never came back.
19
+ //
20
+ // All three are abandoned through the lifecycle port's releaseStuckTweak (no compose,
21
+ // so the note is the reason and nothing is written to the description). The
22
+ // engineer tasks a submit filed are untouched: they carry the artist's words.
23
+ //
24
+ // `closeReason` is pure; `runAutoClose` is the I/O the poller calls. A round
25
+ // someone holds, or one touched within GRACE_MS, is never closed: an artist who
26
+ // just released a draft to come back to it in a minute keeps it.
27
+
28
+ const api = require('../../src/module-api');
29
+ const pages = require('./pages');
30
+ const pageStatus = require('./page-status');
31
+
32
+ const GRACE_MS = Number(process.env.COPY_AUTO_CLOSE_GRACE_MS) || 60 * 60_000;
33
+
34
+ const NOTES = Object.freeze({
35
+ blank: 'Closed by itself: the round was opened but nothing was written in it.',
36
+ nothing_to_apply: 'Closed by itself: every rewritten line went to an engineer task, so nothing was left to apply on the page.',
37
+ draft_expired: 'Closed by itself: the draft sat untouched for two weeks. Its words stay on this task; claim the page again to start over.',
38
+ });
39
+
40
+ function closeReason(task, { now = Date.now(), graceMs = GRACE_MS, draftExpiresMs = pageStatus.DRAFT_EXPIRES_AFTER } = {}) {
41
+ if (!task || task.status !== 'ready' || task.holder_id != null) return null;
42
+ const touched = Date.parse(task.updated_at || task.created_at || '');
43
+ if (!Number.isFinite(touched) || now - touched < graceMs) return null;
44
+ const p = pages.blockPresence(task.description);
45
+ // task 1004901: a draft its artist left untouched for two weeks expires.
46
+ if (!p.batch) return p.draft ? (now - touched >= draftExpiresMs ? 'draft_expired' : null) : 'blank';
47
+ const b = pages.parseBatchBlock(task.description);
48
+ if (b.ok && b.batch.lines.length === 0) return 'nothing_to_apply';
49
+ return null;
50
+ }
51
+
52
+ // One sweep. Returns what happened per round, so the poller and the tests read
53
+ // the same outcome. The actor is the project owner (a system act, recorded on
54
+ // the task as its abandon reason); no owner, no sweep.
55
+ async function runAutoClose({ now = Date.now(), resolve = (k) => (api.resolveOptional ? api.resolveOptional(k) : null), founder = () => api.getFoundingBuilder() } = {}) {
56
+ const lifecycle = resolve('lifecycle');
57
+ if (!lifecycle || typeof lifecycle.listPageTweakTasks !== 'function' || typeof lifecycle.releaseStuckTweak !== 'function') {
58
+ return { closed: [], skipped: 'no_lifecycle' };
59
+ }
60
+ const tasks = await lifecycle.listPageTweakTasks();
61
+ const due = tasks.map((t) => ({ t, why: closeReason(t, { now }) })).filter((x) => x.why);
62
+ if (!due.length) return { closed: [] };
63
+ const owner = await founder();
64
+ if (!owner) return { closed: [], skipped: 'no_owner' };
65
+ const closed = [];
66
+ for (const { t, why } of due) {
67
+ const r = await lifecycle.releaseStuckTweak({ taskId: t.id, builderId: owner.id, expectedStatus: 'ready', note: NOTES[why] });
68
+ closed.push({ task_id: String(t.id), why, outcome: r.outcome });
69
+ }
70
+ return { closed };
71
+ }
72
+
73
+ 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
  }
@@ -306,22 +306,29 @@ function orderedSurfaces(inventory) {
306
306
  // (applied rounds only) stayed empty: the artist's finished work looked lost.
307
307
  // This is every page with an open round, where it is, since when (the round's
308
308
  // last update), and whether it has sat there too long. Waiting for review is
309
- // the artist's own move and a draft is theirs to finish; a draft is never stuck,
310
- // and a review is stuck only after 3 days (task 1004777, so its artist is told);
311
- // a state the reads cannot name always needs to be reviewed.
309
+ // the artist's own move and a draft is theirs to finish: a draft or a page
310
+ // being written is stuck after 7 days and a draft expires after 14 (task
311
+ // 1004901); a review is stuck after 3 days (task 1004777, so its artist is
312
+ // told); a state the reads cannot name always needs to be reviewed.
312
313
  // ---------------------------------------------------------------------------
313
314
 
314
315
  const DAY_MS = 24 * 60 * 60 * 1000;
316
+ // task 1004901: every state names WHO moves it next (waits_on: 'artist' is the
317
+ // round's own artist, 'appliers' the Metic and up builders who apply and land
318
+ // work) and WHEN it is overdue (stuck_after: then it is pushed to that person on
319
+ // Home and in the daily digest). A draft nobody touches also EXPIRES
320
+ // (expires_after): the auto-close sweep abandons it, its words kept on the task.
321
+ const DRAFT_EXPIRES_AFTER = 14 * DAY_MS;
315
322
  const IN_FLIGHT = Object.freeze({
316
- draft: { word: 'Being written', stuck_after: null },
317
- writing: { word: 'Being written', stuck_after: null },
318
- words_review: { word: 'Words waiting for review', stuck_after: 3 * DAY_MS },
319
- submitted: { word: 'Approved, waiting to be applied', stuck_after: 3 * DAY_MS },
320
- applying: { word: 'Being applied', stuck_after: DAY_MS },
323
+ draft: { word: 'Being written', stuck_after: 7 * DAY_MS, waits_on: 'artist', expires_after: DRAFT_EXPIRES_AFTER },
324
+ writing: { word: 'Being written', stuck_after: 7 * DAY_MS, waits_on: 'artist' },
325
+ words_review: { word: 'Words waiting for review', stuck_after: 3 * DAY_MS, waits_on: 'artist' },
326
+ submitted: { word: 'Approved, waiting to be applied', stuck_after: 3 * DAY_MS, waits_on: 'appliers' },
327
+ applying: { word: 'Being applied', stuck_after: DAY_MS, waits_on: 'appliers' },
321
328
  // task 1004777: a review left 3 days is stuck too, so its artist is told on Home.
322
- waiting_for_artist: { word: 'Waiting for your review', stuck_after: 3 * DAY_MS },
323
- landing: { word: 'Approved, going live', stuck_after: DAY_MS },
324
- unknown: { word: 'Needs to be reviewed', stuck_after: 0 },
329
+ waiting_for_artist: { word: 'Waiting for your review', stuck_after: 3 * DAY_MS, waits_on: 'artist' },
330
+ landing: { word: 'Approved, going live', stuck_after: DAY_MS, waits_on: 'appliers' },
331
+ unknown: { word: 'Needs to be reviewed', stuck_after: 0, waits_on: 'appliers' },
325
332
  });
326
333
  const IN_FLIGHT_ORDER = ['unknown', 'landing', 'applying', 'submitted', 'waiting_for_artist', 'words_review', 'writing', 'draft'];
327
334
 
@@ -344,6 +351,9 @@ function inFlightFor(list, byPage, nowMs) {
344
351
  state_word: kind.word,
345
352
  since,
346
353
  stuck: kind.stuck_after != null && (kind.stuck_after === 0 || (age != null && age > kind.stuck_after)),
354
+ waits_on: kind.waits_on,
355
+ due_at: since && kind.stuck_after != null ? new Date(Date.parse(since) + kind.stuck_after).toISOString() : null,
356
+ expires_at: since && kind.expires_after != null ? new Date(Date.parse(since) + kind.expires_after).toISOString() : null,
347
357
  holder: o.holder,
348
358
  artist_id: r.artist_id != null ? String(r.artist_id) : null,
349
359
  });
@@ -380,8 +390,8 @@ function stuckPageTweaks({ inventory, tasks, now = Date.now() }) {
380
390
  state_word: f.state_word,
381
391
  since: f.since,
382
392
  days: ms == null ? null : Math.max(0, Math.floor(ms / DAY_MS)),
383
- // Submitted words (task 1004900) wait on their artist's review, like an applied page.
384
- waits_on: f.state === 'waiting_for_artist' || f.state === 'words_review' ? 'artist' : 'appliers',
393
+ // The state's own owner (task 1004901), one table for the Studio and the pushes.
394
+ waits_on: f.waits_on,
385
395
  artist_id: f.artist_id,
386
396
  };
387
397
  });
@@ -968,4 +978,6 @@ module.exports = {
968
978
  stuckPageTweaks,
969
979
  stuckPageTweaksFor,
970
980
  stuckDigestText,
981
+ // task 1004901: when an untouched draft expires (the auto-close sweep reads it).
982
+ DRAFT_EXPIRES_AFTER,
971
983
  };
@@ -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,100 @@
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('a draft left untouched for two weeks expires; a younger one stays (task 1004901)', () => {
60
+ const D = 86400000;
61
+ const at = (days) => round({ description: draft, updated_at: new Date(NOW - days * D).toISOString() });
62
+ assert.equal(autoClose.closeReason(at(13.9), { now: NOW }), null);
63
+ assert.equal(autoClose.closeReason(at(14.1), { now: NOW }), 'draft_expired');
64
+ assert.equal(autoClose.closeReason({ ...at(30), holder_id: '7' }, { now: NOW }), null, 'a held draft is being written, never expired');
65
+ });
66
+
67
+ await test('the grace period is honoured: a round touched within it stays', () => {
68
+ const t = round({ updated_at: new Date(NOW - 59 * 60_000).toISOString() });
69
+ assert.equal(autoClose.closeReason(t, { now: NOW, graceMs: 60 * 60_000 }), null);
70
+ assert.equal(autoClose.closeReason(t, { now: NOW, graceMs: 30 * 60_000 }), 'blank');
71
+ });
72
+
73
+ await test('the sweep abandons through the lifecycle port, as the owner, with a plain note and no rewrite', async () => {
74
+ const calls = [];
75
+ const lifecycle = {
76
+ listPageTweakTasks: async () => Object.values(ROUNDS),
77
+ releaseStuckTweak: async (args) => { calls.push(args); return { outcome: 'abandoned' }; },
78
+ };
79
+ const r = await autoClose.runAutoClose({ now: NOW, resolve: () => lifecycle, founder: async () => ({ id: '1' }) });
80
+ assert.deepEqual(r.closed.map((c) => [c.task_id, c.why, c.outcome]), [['11', 'blank', 'abandoned'], ['12', 'nothing_to_apply', 'abandoned']]);
81
+ for (const c of calls) {
82
+ assert.equal(c.builderId, '1');
83
+ assert.equal(c.expectedStatus, 'ready');
84
+ assert.equal('compose' in c, false, 'no compose: the round is abandoned, never rewritten');
85
+ 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');
86
+ }
87
+ });
88
+
89
+ await test('nothing due, no owner read; no owner, no sweep; no lifecycle, no sweep', async () => {
90
+ let asked = 0;
91
+ const quiet = { listPageTweakTasks: async () => [ROUNDS.real_batch], releaseStuckTweak: async () => { throw new Error('must not run'); } };
92
+ assert.deepEqual(await autoClose.runAutoClose({ now: NOW, resolve: () => quiet, founder: async () => { asked++; return { id: '1' }; } }), { closed: [] });
93
+ assert.equal(asked, 0);
94
+ const due = { listPageTweakTasks: async () => [ROUNDS.blank], releaseStuckTweak: async () => { throw new Error('must not run'); } };
95
+ assert.equal((await autoClose.runAutoClose({ now: NOW, resolve: () => due, founder: async () => null })).skipped, 'no_owner');
96
+ assert.equal((await autoClose.runAutoClose({ now: NOW, resolve: () => null })).skipped, 'no_lifecycle');
97
+ });
98
+
99
+ console.log(`\n${passed} passed, ${failed} failed`);
100
+ process.exit(failed ? 1 : 0);
@@ -257,7 +257,7 @@ ${pages.composeWordsApprovedBlock({ at: ago(9), by: '7' })}`;
257
257
  round(2, 'landing:b', { status: 'ready', description: `${pages.roundTaskBody(pg('landing:b'), 1)}\n\n${batch('landing:b')}`, updated_at: ago(1) }),
258
258
  round(3, 'landing:c', { status: 'confirmed', updated_at: ago(17) }),
259
259
  round(4, 'builders:d', { status: 'completed', grade_passed: true, updated_at: ago(20) }),
260
- round(5, 'builders:e', { status: 'active', holder_id: '7', holder_login: 'me', holder_worktree: false, updated_at: ago(30) }),
260
+ round(5, 'builders:e', { status: 'active', holder_id: '7', holder_login: 'me', holder_worktree: false, updated_at: ago(5) }),
261
261
  round(6, 'builders:f', { status: 'ready', updated_at: ago(0.2) }),
262
262
  round(7, 'builders:g', { status: 'shipped', shipped_at: ago(3), updated_at: ago(3) }),
263
263
  round(8, 'status:h', { status: 'active', holder_id: '8', holder_worktree: true, updated_at: ago(2) }),
@@ -271,7 +271,7 @@ ${pages.composeWordsApprovedBlock({ at: ago(9), by: '7' })}`;
271
271
  ['builders:d', 'waiting_for_artist', 'Waiting for your review', true],
272
272
  ['landing:b', 'submitted', 'Approved, waiting to be applied', false],
273
273
  ['builders:e', 'writing', 'Being written', false],
274
- ], 'a shipped round is not in flight; writing is never stuck, and a review left 20 days is (task 1004777)');
274
+ ], 'a shipped round is not in flight; writing is not overdue before 7 days (task 1004901), and a review left 20 days is (task 1004777)');
275
275
  assert.equal(out.in_flight[1].since, ago(17));
276
276
  assert.equal(out.in_flight[1].task_id, '3');
277
277
  assert.equal(out.pages.find((p) => p.id === 'landing:a').status_label, 'Untweaked', 'the status still counts only what shipped');
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.18",
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.18",
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.18",
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,17 @@
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
+ }
10494
+ ],
10495
+ "1.23.18": [
10496
+ {
10497
+ "id": "1004901",
10498
+ "text": "Every stage of a page tweak now has a named person and a deadline. Drafts left alone get a reminder after a week and close after two, so no page sits in the Studio without someone being told."
10499
+ }
10488
10500
  ]
10489
10501
  }
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.18'; // 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');
@@ -34,8 +34,8 @@ const TASKS = [
34
34
  round(2, 'landing:account', { status: 'completed', grade_passed: true, updated_at: ago(5) }),
35
35
  // submitted yesterday: NOT stuck
36
36
  round(3, 'builders:studio', { status: 'ready', description: `${pages.roundTaskBody(pg('builders:studio'), 1)}\n\n${batch('builders:studio')}`, updated_at: ago(1) }),
37
- // being written for a month: a draft is never stuck
38
- round(4, 'landing:projects', { status: 'active', holder_id: '7', holder_worktree: false, updated_at: ago(30) }),
37
+ // being written for 3 days: not overdue (that is 7 days, task 1004901)
38
+ round(4, 'landing:projects', { status: 'active', holder_id: '7', holder_worktree: false, updated_at: ago(3) }),
39
39
  ];
40
40
  const stuck = () => status.stuckPageTweaks({ inventory: INVENTORY, tasks: TASKS, now: NOW });
41
41
 
@@ -46,6 +46,43 @@ await test('the composer: only stuck pages, each with where it sits, how long, a
46
46
  ]);
47
47
  });
48
48
 
49
+ // task 1004901: every state names who moves it next and when it is overdue.
50
+ await test('every state has an owner and a deadline: overdue only past it, told to that owner', () => {
51
+ const D = 86400000;
52
+ const p = (id) => pg(id);
53
+ const desc = (id, blocks) => [pages.roundTaskBody(p(id), 1), ...blocks].join('\n\n');
54
+ const draftBlock = (id) => pages.composeDraftBlock({ page_id: id, reading_hash: 'h', saved_at: ago(1), lines: [] });
55
+ const batchBlock = (id) => pages.composeBatchBlock({ page_id: id, reading_hash: 'h', submitted_at: ago(1), lines: [] });
56
+ const approved = pages.composeWordsApprovedBlock({ at: ago(1), by: ARTIST });
57
+ // [state, task shape, owner, days until overdue]
58
+ const table = [
59
+ ['draft', (id) => ({ status: 'ready', description: desc(id, [draftBlock(id)]) }), 'artist', 7],
60
+ ['writing', () => ({ status: 'active', holder_id: ARTIST, holder_worktree: false }), 'artist', 7],
61
+ ['words_review', (id) => ({ status: 'ready', description: desc(id, [batchBlock(id)]) }), 'artist', 3],
62
+ ['submitted', (id) => ({ status: 'ready', description: desc(id, [batchBlock(id), approved]) }), 'appliers', 3],
63
+ ['applying', () => ({ status: 'active', holder_id: '99', holder_worktree: true }), 'appliers', 1],
64
+ ['waiting_for_artist', () => ({ status: 'completed', grade_passed: true }), 'artist', 3],
65
+ ['landing', () => ({ status: 'confirmed' }), 'appliers', 1],
66
+ ['unknown', () => ({ status: 'ready' }), 'appliers', 0],
67
+ ];
68
+ for (const [state, shape, owner, days] of table) {
69
+ const id = 'landing:projects';
70
+ const at = (age) => status.composePages({ inventory: INVENTORY, readings: null, tasks: [round(50, id, { ...shape(id), updated_at: ago(age) })], now: NOW }).in_flight[0];
71
+ const before = at(Math.max(days - 0.5, 0));
72
+ assert.equal(before.state, state);
73
+ assert.equal(before.waits_on, owner, `${state} waits on ${owner}`);
74
+ assert.equal(Date.parse(before.due_at) - Date.parse(before.since), days * D, `${state} is overdue after ${days} day(s)`);
75
+ if (days > 0) assert.equal(before.stuck, false, `${state} is not overdue before its deadline`);
76
+ const after = at(days + 0.5);
77
+ assert.equal(after.stuck, true, `${state} is overdue after its deadline`);
78
+ const told = status.stuckPageTweaksFor({ builderId: ARTIST, rank: 'xenos', stuck: status.stuckPageTweaks({ inventory: INVENTORY, tasks: [round(50, id, { ...shape(id), updated_at: ago(days + 0.5) })], now: NOW }) });
79
+ assert.equal(told.length, owner === 'artist' ? 1 : 0, `${state}: its artist is told only when it waits on them`);
80
+ }
81
+ const draft = status.composePages({ inventory: INVENTORY, readings: null, tasks: [round(51, 'landing:projects', { status: 'ready', description: desc('landing:projects', [draftBlock('landing:projects')]), updated_at: ago(2) })], now: NOW }).in_flight[0];
82
+ assert.equal(Date.parse(draft.expires_at) - Date.parse(draft.since), status.DRAFT_EXPIRES_AFTER, 'a draft says when it expires');
83
+ assert.equal(status.DRAFT_EXPIRES_AFTER, 14 * D);
84
+ });
85
+
49
86
  await test('submitted words left unreviewed wait on their artist, not the appliers (task 1004900)', () => {
50
87
  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
88
  const got = status.stuckPageTweaks({ inventory: INVENTORY, tasks: [words], now: NOW });