@bongos/core 1.21.106 → 1.21.108

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 (50) hide show
  1. package/.bongos-core.json +72 -52
  2. package/.claude/skills/goal-create/SKILL.md +8 -3
  3. package/.claude/skills/idea-triage/SKILL.md +1 -1
  4. package/.claude/skills/planning-session/SKILL.md +6 -4
  5. package/clients/bongos-client/README.md +1 -1
  6. package/clients/bongos-client/bongos-client.global.js +4 -0
  7. package/clients/bongos-client/index.cjs +4 -0
  8. package/clients/bongos-client/index.d.ts +6 -1
  9. package/clients/bongos-client/index.mjs +4 -0
  10. package/docs/api/openapi.json +91 -2
  11. package/docs/api-reference.md +5 -3
  12. package/docs/copy-inventory.md +25 -19
  13. package/docs/copy-registry.json +92 -37
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-readings.json +189 -188
  16. package/migrations/core_275_claims_auto_pickup.sql +24 -0
  17. package/modules/copy-desk/docx.js +27 -7
  18. package/modules/copy-desk/page-status.js +3 -1
  19. package/modules/copy-desk/pages.js +44 -5
  20. package/modules/copy-desk/routes/copy-desk.js +4 -3
  21. package/modules/copy-desk/tests/copy_docx.mjs +38 -0
  22. package/modules/copy-desk/tests/copy_page_draft.mjs +79 -0
  23. package/modules/government/decision-rules.js +1 -0
  24. package/modules/hall-ui/public/paintbrush.js +152 -12
  25. package/modules/hall-ui/public/studio.js +3 -1
  26. package/modules/hall-ui/public/style.css +16 -0
  27. package/modules/hall-ui/public/tweak-editor-lib.js +24 -12
  28. package/modules/hall-ui/records/craft-mode.md +5 -0
  29. package/modules/hall-ui/records/tweak-editor.md +1 -1
  30. package/modules/lifecycle/autopickup.js +94 -0
  31. package/modules/lifecycle/db-claims.js +18 -4
  32. package/modules/lifecycle/routes/claims.js +12 -1
  33. package/modules/lifecycle/routes/project-settings.js +59 -0
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/release-notes.json +16 -0
  37. package/scripts/gds/government-walk.js +1 -0
  38. package/scripts/gds/run-unit-tests.js +3 -0
  39. package/scripts/gds/seed-bv2-ideator-surface-tasks.js +12 -21
  40. package/scripts/gds/seed-verify-criteria.js +73 -18
  41. package/src/bongos/project-settings.js +79 -0
  42. package/src/module-api.js +8 -1
  43. package/tests/autopickup_cap.mjs +236 -0
  44. package/tests/autopickup_cap_db.mjs +115 -0
  45. package/tests/copy_desk_page_submit.mjs +36 -0
  46. package/tests/government_act_alone_buildings.mjs +1 -0
  47. package/tests/hall_tweak_editor.mjs +19 -3
  48. package/tests/paintbrush.mjs +151 -2
  49. package/tests/planning_session_skill.mjs +40 -0
  50. package/tests/seed_verify_criteria.mjs +130 -0
@@ -31,6 +31,7 @@
31
31
  const express = require('express');
32
32
  const api = require('../../../src/module-api');
33
33
  const goalCategory = require('../goal-category');
34
+ const autopickup = require('../autopickup');
34
35
 
35
36
  const log = api.logger('lifecycle');
36
37
 
@@ -380,6 +381,64 @@ module.exports = function buildProjectSettingsRouter() {
380
381
  }
381
382
  });
382
383
 
384
+ // --- tweak auto-pickup (task 1004677, ADR 0371 D3/D5) ----------------------
385
+ //
386
+ // GET /project-settings/tweak-autopickup — the switch, the monthly cap, and how
387
+ // many rounds the lane has picked up this calendar month (counted from marked
388
+ // claims, never a stored counter).
389
+ //
390
+ // rank: any signed-in builder. The runner reads it before it tries a round, and
391
+ // the studio shows the count; it carries no reward internals or file paths.
392
+ async function autopickupBody() {
393
+ const resolved = await api.projectSettings.resolveTweakAutopickup();
394
+ const used = await autopickup.countThisMonth();
395
+ return {
396
+ mode: resolved.mode,
397
+ cap: resolved.cap,
398
+ used_this_month: used,
399
+ remaining_this_month: Math.max(0, resolved.cap - used),
400
+ per_round_limit: autopickup.MAX_AUTO_APPLIES_PER_ROUND,
401
+ source: resolved.source,
402
+ invalid: resolved.invalid,
403
+ sentence: api.projectSettings.describeTweakAutopickup(resolved),
404
+ };
405
+ }
406
+
407
+ router.get('/project-settings/tweak-autopickup', api.requireBuilder, async (req, res) => {
408
+ try {
409
+ res.set('Cache-Control', 'no-store');
410
+ res.json(await autopickupBody());
411
+ } catch (err) {
412
+ log.error(`GET /project-settings/tweak-autopickup: ${err && err.message}`);
413
+ res.fail('project_settings_read_failed', { status: 500, message: 'internal error' });
414
+ }
415
+ });
416
+
417
+ // PATCH /project-settings/tweak-autopickup — body { mode?: 'off' | 'on', cap?: n }.
418
+ //
419
+ // rank: project.settings.manage (metic+, government_010), the same atom as the
420
+ // other writes on this page. Each field is validated by the resolver's own parser,
421
+ // and both are checked before either is written, so a half-valid body writes nothing.
422
+ router.patch('/project-settings/tweak-autopickup', api.requireBuilder, api.requirePermission('project.settings.manage'), api.requireActAlone('rules'), async (req, res) => {
423
+ if (api.validateOrRespond(req, res, { mode: { type: 'string' }, cap: { type: 'number' } })) return;
424
+ const body = req.body || {};
425
+ if (body.mode == null && body.cap == null) {
426
+ return res.fail('bad_tweak_autopickup', { status: 400, message: 'send mode, cap, or both' });
427
+ }
428
+ const mode = body.mode == null ? null : api.projectSettings.parseAutopickup(body.mode);
429
+ if (mode && !mode.mode) return res.fail('bad_tweak_autopickup', { status: 400, message: `mode: ${mode.reason}` });
430
+ const cap = body.cap == null ? null : api.projectSettings.parseAutopickupCap(body.cap);
431
+ if (cap && cap.cap == null) return res.fail('bad_tweak_autopickup', { status: 400, message: `cap: ${cap.reason}` });
432
+ try {
433
+ if (mode) await api.projectSettings.set(api.projectSettings.KEYS.TWEAK_AUTOPICKUP, mode.mode, req.builder.id);
434
+ if (cap) await api.projectSettings.set(api.projectSettings.KEYS.TWEAK_AUTOPICKUP_CAP, cap.cap, req.builder.id);
435
+ res.json({ ok: true, ...(await autopickupBody()) });
436
+ } catch (err) {
437
+ log.error(`PATCH /project-settings/tweak-autopickup: ${err && err.message}`);
438
+ res.fail('project_settings_write_failed', { status: 500, message: 'internal error' });
439
+ }
440
+ });
441
+
383
442
  return router;
384
443
  };
385
444
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.106",
3
+ "version": "1.21.108",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.106",
9
+ "version": "1.21.108",
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.21.106",
3
+ "version": "1.21.108",
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",
@@ -9482,5 +9482,21 @@
9482
9482
  "id": "1004683",
9483
9483
  "text": "After pressing Create, a founder now sees \"Getting <name> ready\" with a plain count (\"4 of 6 done\", or \"ready\" with a tick) and one sentence saying what is happening and whether it is their turn. Below it are two short lists,"
9484
9484
  }
9485
+ ],
9486
+ "1.21.107": [
9487
+ {
9488
+ "id": "1004677",
9489
+ "text": "A project now has a switch for letting a runner pick up submitted page tweaks on its own, off by default, with a monthly limit of 20 rounds. The server enforces the limit when the work is claimed, so two runners can never both"
9490
+ }
9491
+ ],
9492
+ "1.21.108": [
9493
+ {
9494
+ "id": "1004668",
9495
+ "text": "Adding new tasks to a goal that already has work in it no longer sets off a false alarm saying the tasks were filed in the wrong place. The planning guides also stop telling people that new tasks wait for an owner's go-ahead:"
9496
+ },
9497
+ {
9498
+ "id": "1004696",
9499
+ "text": "Artists can now recommend removing a line while editing a page's words: mark it with \"Remove this line\" or just delete its words, see it crossed out, keep it after all, and send it with their rewrites. Each removal goes to an"
9500
+ }
9485
9501
  ]
9486
9502
  }
@@ -70,6 +70,7 @@ const BAD = 'x';
70
70
  const REQUESTS = Object.freeze({
71
71
  'PATCH /project-settings/rot-days': ['PATCH', '/project-settings/rot-days', { days: 'soon' }],
72
72
  'PATCH /project-settings/ship-mode': ['PATCH', '/project-settings/ship-mode', { mode: 'sometimes' }],
73
+ 'PATCH /project-settings/tweak-autopickup': ['PATCH', '/project-settings/tweak-autopickup', { mode: 'sometimes' }],
73
74
  'PATCH /work-categories/:id': ['PATCH', `/work-categories/${BAD}`, {}],
74
75
  'POST /security/reports/:id/confirm': ['POST', `/security/reports/${BAD}/confirm`, {}],
75
76
  'POST /security/reports/:id/dispute': ['POST', `/security/reports/${BAD}/dispute`, {}],
@@ -121,6 +121,9 @@ const INTEGRATION = new Set([
121
121
  // index actually refuses the commingling INSERT and still allows the parallel
122
122
  // -worktree case. It self-skips without a DB, and refuses any database that
123
123
  // has a `builders` table, since it TRUNCATEs claims.
124
+ // task 1004677: two marked auto-pickup claims racing for the last slot of the
125
+ // monthly cap; only real connections prove the advisory lock. Scratch DB only.
126
+ 'autopickup_cap_db',
124
127
  'claims_requested_worktree_db',
125
128
  'clawback_confirmed_backlog',
126
129
  // task 1003113: the copy_desk_flags schema half. The module's own tests are
@@ -13,7 +13,7 @@
13
13
  //
14
14
  // Run: node scripts/gds/seed-bv2-ideator-surface-tasks.js
15
15
  const os = require('os');
16
- const { expectedCriterionCounts, verifySeedCriteriaLinks } = require('./seed-verify-criteria');
16
+ const { baselineSeedCriteria, expectedCriterionCounts, verifySeedCriteriaLinks } = require('./seed-verify-criteria');
17
17
  const sess = require(os.homedir() + '/.config/cloudbongos/gds-session.json');
18
18
  const BASE = sess.api_base.replace(/\/$/, '');
19
19
  const TOKEN = sess.token;
@@ -328,24 +328,11 @@ async function main() {
328
328
  const ids = {};
329
329
  const criterionIdListsPerTask = [];
330
330
 
331
- // BASELINE. verifySeedCriteriaLinks unions the plan's slugs with EVERY criterion
332
- // the goal carries, so a criterion this seed does not touch is compared against an
333
- // expected zero. Goal 1000095 already held six criteria with live task links before
334
- // this seed existed, so an unbaselined check would report six false mismatches and
335
- // exit(1) on a correct run. Snapshot the untouched criteria's real counts HERE,
336
- // before anything is written, and fold them into the plan — the check then still
337
- // catches any drift on the four this seed owns, which is what it is for.
338
- const OURS = new Set(Object.values(CRIT));
339
- const baseline = {};
340
- const pre = await api('GET', `/goals/${GOAL}`);
341
- if (!pre.ok || !pre.json || !Array.isArray(pre.json.criteria)) {
342
- console.error(`Could not read goal ${GOAL} to baseline its existing criteria (status ${pre.status}). Refusing to seed blind.`);
343
- process.exit(1);
344
- }
345
- for (const row of pre.json.criteria) {
346
- if (!OURS.has(row.criterion_id)) baseline[row.criterion_id] = Number(row.linked_tasks) || 0;
347
- }
348
- console.log(`Baselined ${Object.keys(baseline).length} pre-existing criteria on goal ${GOAL}.`);
331
+ // BASELINE, before anything is written. Goal 1000095 already held six criteria
332
+ // with live task links before this seed existed; verifying against the pre-seed
333
+ // snapshot plus the plan keeps those from reading as false mismatches while still
334
+ // catching any drift (task 1004668 moved this into seed-verify-criteria.js).
335
+ const baseline = await baselineSeedCriteria({ goalId: GOAL, fetchGoal: (gid) => api('GET', `/goals/${gid}`) });
349
336
 
350
337
  console.log(`Seeding ${TASKS.length} tasks into goal ${GOAL} (${VER})…\n`);
351
338
 
@@ -370,7 +357,10 @@ async function main() {
370
357
  continue;
371
358
  }
372
359
  ids[t.ref] = r.json.task.id;
373
- criterionIdListsPerTask.push(t.crit);
360
+ // Count only links THIS run created. A re-run replays an existing task
361
+ // (`idempotent: true`) whose links the baseline already holds; counting it
362
+ // again would expect them twice (task 1004668).
363
+ if (!r.json.idempotent) criterionIdListsPerTask.push(t.crit);
374
364
  console.log(` ${t.ref} → ${r.json.task.id} ${title.slice(0, 74)}`);
375
365
  }
376
366
 
@@ -391,7 +381,8 @@ async function main() {
391
381
  console.log('');
392
382
  await verifySeedCriteriaLinks({
393
383
  goalId: GOAL,
394
- expectedCounts: { ...baseline, ...expectedCriterionCounts(criterionIdListsPerTask) },
384
+ expectedCounts: expectedCriterionCounts(criterionIdListsPerTask),
385
+ baseline,
395
386
  fetchGoal: (gid) => api('GET', `/goals/${gid}`),
396
387
  });
397
388
  }
@@ -12,21 +12,48 @@
12
12
  // (some use cli-lib's apiCall, others a local throttled fetch — see the
13
13
  // scripts/gds/CLAUDE.md roster), so this takes a `fetchGoal(goalId)` callback
14
14
  // rather than assuming a client shape.
15
+ //
16
+ // WHY A BASELINE (task 1004668). A seed into a goal that already carries linked
17
+ // work used to cry wolf: every criterion the seed did not touch was compared
18
+ // against an expected count of zero, so goal 1000095 (six criteria already linked)
19
+ // would have reported six false mismatches and exit(1) on a correct run — and a
20
+ // check that fails correct runs trains the next builder to ignore it. A seed now
21
+ // snapshots the goal's counts BEFORE its first write (baselineSeedCriteria) and
22
+ // verifies `baseline + plan`, which still catches a stray link on a criterion the
23
+ // seed was never meant to touch. Without a baseline only the plan's own criteria
24
+ // are checked; nothing else is assumed to be zero.
25
+ //
26
+ // With a baseline, the plan is the links THIS RUN CREATED, not every task the seed
27
+ // names. A seed is resumable (POST /tasks is idempotent on source_ref and replays
28
+ // an existing task with `idempotent: true`), and a replayed task's links are
29
+ // already inside the baseline — tally it again and a correct re-run expects them
30
+ // twice. So a baselined seed skips replays when it tallies (see
31
+ // seed-bv2-ideator-surface-tasks.js).
32
+
33
+ // countsByCriterion — { criterion_id: linked_tasks } from GET /goals/:id rows.
34
+ function countsByCriterion(criteriaRows) {
35
+ const counts = {};
36
+ for (const row of criteriaRows || []) counts[row.criterion_id] = Number(row.linked_tasks) || 0;
37
+ return counts;
38
+ }
15
39
 
16
40
  // diffCriterionLinks — pure comparison: expected (slug -> count, tallied from
17
41
  // the criterion_ids actually POSTed) vs actual (the `criteria` rows
18
42
  // GET /goals/:id returns, each carrying criterion_id + linked_tasks). Returns
19
- // every mismatching slug, including one present on only one side.
20
- function diffCriterionLinks(expectedCounts, criteriaRows) {
21
- const actual = {};
22
- for (const row of criteriaRows || []) {
23
- actual[row.criterion_id] = Number(row.linked_tasks) || 0;
24
- }
25
-
26
- const slugs = new Set([...Object.keys(expectedCounts || {}), ...Object.keys(actual)]);
43
+ // every mismatching slug.
44
+ //
45
+ // With `baseline` (the pre-seed snapshot) every criterion on the goal is checked
46
+ // against baseline + plan — a criterion created after the snapshot starts at 0.
47
+ // Without it, only the plan's own criteria are checked: a criterion the seed
48
+ // does not cite has an unknown prior count, and guessing zero is the false alarm.
49
+ function diffCriterionLinks(expectedCounts, criteriaRows, { baseline = null } = {}) {
50
+ const actual = countsByCriterion(criteriaRows);
51
+ const plan = expectedCounts || {};
52
+ const slugs = new Set(Object.keys(plan));
53
+ if (baseline) for (const slug of [...Object.keys(baseline), ...Object.keys(actual)]) slugs.add(slug);
27
54
  const mismatches = [];
28
55
  for (const slug of slugs) {
29
- const expected = (expectedCounts && expectedCounts[slug]) || 0;
56
+ const expected = ((baseline && baseline[slug]) || 0) + (plan[slug] || 0);
30
57
  const got = actual[slug] || 0;
31
58
  if (expected !== got) mismatches.push({ criterion_id: slug, expected, actual: got });
32
59
  }
@@ -45,25 +72,53 @@ function expectedCriterionCounts(criterionIdListsPerTask) {
45
72
  return counts;
46
73
  }
47
74
 
75
+ function readCriteria(r) {
76
+ const body = (r && (r.json || r.data)) || null;
77
+ return r && r.ok && body && Array.isArray(body.criteria) ? body.criteria : null;
78
+ }
79
+
80
+ // baselineSeedCriteria — call BEFORE the seed's first write. Returns the goal's
81
+ // current { criterion_id: linked_tasks } snapshot to hand to
82
+ // verifySeedCriteriaLinks, or exit(1)s: a seed that cannot read the goal it is
83
+ // about to write into must not seed blind.
84
+ async function baselineSeedCriteria({ goalId, fetchGoal, log = console.log, exit = process.exit }) {
85
+ const r = await fetchGoal(goalId);
86
+ const criteria = readCriteria(r);
87
+ if (!criteria) {
88
+ log(`BASELINE FAILED: GET goal ${goalId} did not return a criteria list (status ${r && r.status}). Refusing to seed blind.`);
89
+ return exit(1);
90
+ }
91
+ const baseline = countsByCriterion(criteria);
92
+ const linked = Object.values(baseline).filter((n) => n > 0).length;
93
+ log(`Baselined goal ${goalId}: ${criteria.length} criteria, ${linked} already carrying linked tasks.`);
94
+ return baseline;
95
+ }
96
+
48
97
  // verifySeedCriteriaLinks — Phase-5 self-check: fetch the goal, diff its
49
- // linked_tasks against the plan, and exit(1) loudly on any mismatch (a zero
50
- // where tasks were expected is the shape that shipped silently in the
51
- // RETRO2 incident, but any drift is worth stopping the seed for).
52
- async function verifySeedCriteriaLinks({ goalId, expectedCounts, fetchGoal, log = console.log, exit = process.exit }) {
98
+ // linked_tasks against the plan (plus the pre-seed `baseline` when given), and
99
+ // exit(1) loudly on any mismatch (a zero where tasks were expected is the shape
100
+ // that shipped silently in the RETRO2 incident, but any drift is worth stopping
101
+ // the seed for).
102
+ async function verifySeedCriteriaLinks({ goalId, expectedCounts, baseline = null, fetchGoal, log = console.log, exit = process.exit }) {
53
103
  const r = await fetchGoal(goalId);
54
- const body = (r && (r.json || r.data)) || null;
55
- if (!r || !r.ok || !body || !Array.isArray(body.criteria)) {
104
+ const criteria = readCriteria(r);
105
+ if (!criteria) {
56
106
  log(`VERIFY FAILED: GET goal ${goalId} did not return a criteria list (status ${r && r.status}). Cannot confirm criterion links landed.`);
57
107
  return exit(1);
58
108
  }
59
- const mismatches = diffCriterionLinks(expectedCounts, body.criteria);
109
+ const mismatches = diffCriterionLinks(expectedCounts, criteria, { baseline });
60
110
  if (mismatches.length) {
61
111
  log(`VERIFY FAILED: criterion links on goal ${goalId} do not match the plan (${mismatches.length} mismatch${mismatches.length === 1 ? '' : 'es'}):`);
62
112
  for (const m of mismatches) log(` ${m.criterion_id}: expected ${m.expected} linked task(s), got ${m.actual}`);
63
113
  log('Likely cause: a criterion_ids mapping bug (e.g. a positional "Cn" token resolved against the wrong scope — learning 1000188). Fix and re-run before trusting this seed.');
64
114
  return exit(1);
65
115
  }
66
- log(`VERIFY OK: all ${Object.keys(expectedCounts || {}).length} criteria on goal ${goalId} carry their planned task count.`);
116
+ if (baseline) {
117
+ log(`VERIFY OK: all ${criteria.length} criteria on goal ${goalId} carry their pre-seed count plus the planned links.`);
118
+ } else {
119
+ log(`VERIFY OK: all ${Object.keys(expectedCounts || {}).length} planned criteria on goal ${goalId} carry their planned task count.`);
120
+ log(' (No baseline was taken, so criteria this seed does not cite were not checked — pass baselineSeedCriteria() output as `baseline` to cover them.)');
121
+ }
67
122
  }
68
123
 
69
- module.exports = { diffCriterionLinks, expectedCriterionCounts, verifySeedCriteriaLinks };
124
+ module.exports = { diffCriterionLinks, expectedCriterionCounts, baselineSeedCriteria, verifySeedCriteriaLinks };
@@ -50,6 +50,8 @@ const SETTING_KEYS = {
50
50
  ROT_DAYS: 'attention.rot_days',
51
51
  FOCUS_VERSION: 'roadmap.focus_version',
52
52
  SHIP_MODE: 'ship.mode',
53
+ TWEAK_AUTOPICKUP: 'tweak.autopickup',
54
+ TWEAK_AUTOPICKUP_CAP: 'tweak.autopickup_monthly_cap',
53
55
  };
54
56
 
55
57
  // SHIP MODE (task 1004628, ADR 0369) — WHO starts a ship, never HOW it lands.
@@ -278,6 +280,75 @@ function describeShipMode(resolved) {
278
280
  return `${what}${how}`;
279
281
  }
280
282
 
283
+
284
+ // TWEAK AUTO-PICKUP (task 1004677, ADR 0371 D3/D5). Whether the Autobongos runner's
285
+ // tweak lane may pick up a submitted page round on its own, and how many rounds it
286
+ // may pick up per calendar month. Off by default: a project that never turns it on
287
+ // keeps today's queue exactly. The cap is ENFORCED by the server at claim time
288
+ // (modules/lifecycle/autopickup.js); this file only resolves the numbers.
289
+ const AUTOPICKUP_MODES = Object.freeze(['off', 'on']);
290
+ const DEFAULT_AUTOPICKUP = 'off';
291
+ // 20 rounds: a grade costs the project a few dollars, so 20 is a bounded, legible
292
+ // monthly spend at about the level of one active artist (ADR 0371 D5).
293
+ const DEFAULT_AUTOPICKUP_CAP = 20;
294
+ // Past 1000 the cap is an off switch typed as a big number; that should be asked
295
+ // for out loud, the same reasoning as MAX_ROT_DAYS.
296
+ const MAX_AUTOPICKUP_CAP = 1000;
297
+
298
+ function parseAutopickup(raw) {
299
+ if (raw == null || String(raw).trim() === '') return { reason: 'empty' };
300
+ const v = String(raw).trim().toLowerCase();
301
+ if (!AUTOPICKUP_MODES.includes(v)) return { reason: `must be one of ${AUTOPICKUP_MODES.join(' | ')}` };
302
+ return { mode: v };
303
+ }
304
+
305
+ function parseAutopickupCap(raw) {
306
+ if (raw == null || raw === '') return { reason: 'empty' };
307
+ const n = Number(raw);
308
+ if (!Number.isFinite(n)) return { reason: 'not a number' };
309
+ if (!Number.isInteger(n)) return { reason: 'must be a whole number of rounds' };
310
+ if (n <= 0) return { reason: 'must be positive' };
311
+ if (n > MAX_AUTOPICKUP_CAP) return { reason: `out of range (1..${MAX_AUTOPICKUP_CAP})` };
312
+ return { cap: n };
313
+ }
314
+
315
+ // One knob through its parser: { value, source, invalid }. Never throws; an
316
+ // unreadable database answers the default, the resolveShipMode posture.
317
+ async function resolveOne(key, parse, pick, fallback) {
318
+ let row = null;
319
+ try {
320
+ row = await getSetting(key);
321
+ } catch (err) {
322
+ logger.error(`project_settings read failed; falling back to the default ${key}: ${err && err.message}`);
323
+ return { value: fallback, source: 'default', invalid: null };
324
+ }
325
+ if (row && row.value != null && row.value !== '') {
326
+ const got = parse(row.value);
327
+ if (pick(got) != null) return { value: pick(got), source: 'project_settings', invalid: null };
328
+ return { value: fallback, source: 'default', invalid: { where: `project_settings['${key}']`, raw: String(row.value), reason: got.reason } };
329
+ }
330
+ return { value: fallback, source: 'default', invalid: null };
331
+ }
332
+
333
+ // resolveTweakAutopickup() — { mode, cap, source: { mode, cap }, invalid: [...] }.
334
+ // mode is always 'off' or 'on', cap always a usable positive integer.
335
+ async function resolveTweakAutopickup() {
336
+ const m = await resolveOne(SETTING_KEYS.TWEAK_AUTOPICKUP, parseAutopickup, (g) => g.mode, DEFAULT_AUTOPICKUP);
337
+ const c = await resolveOne(SETTING_KEYS.TWEAK_AUTOPICKUP_CAP, parseAutopickupCap, (g) => g.cap, DEFAULT_AUTOPICKUP_CAP);
338
+ return {
339
+ mode: m.value,
340
+ cap: c.value,
341
+ source: { mode: m.source, cap: c.source },
342
+ invalid: [m.invalid, c.invalid].filter(Boolean),
343
+ };
344
+ }
345
+
346
+ function describeTweakAutopickup(resolved) {
347
+ const r = resolved || {};
348
+ if (r.mode !== 'on') return 'Off: a submitted page waits until a person runs /tweak for it.';
349
+ return `On: a running Autobongos runner applies submitted pages on its own, up to ${r.cap} a month. The artist still approves every one.`;
350
+ }
351
+
281
352
  // parseDays is exported for the tests only — the range check and the default are
282
353
  // asserted against the SAME constants the resolver reads, because a test that
283
354
  // re-typed 30 or 365 would be the second copy of the number this file exists to
@@ -299,4 +370,12 @@ module.exports = {
299
370
  parseShipMode,
300
371
  resolveShipMode,
301
372
  describeShipMode,
373
+ AUTOPICKUP_MODES,
374
+ DEFAULT_AUTOPICKUP,
375
+ DEFAULT_AUTOPICKUP_CAP,
376
+ MAX_AUTOPICKUP_CAP,
377
+ parseAutopickup,
378
+ parseAutopickupCap,
379
+ resolveTweakAutopickup,
380
+ describeTweakAutopickup,
302
381
  };
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.106'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.108'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -474,6 +474,13 @@ module.exports = {
474
474
  get parseShipMode() { return require('./bongos/project-settings').parseShipMode; },
475
475
  get resolveShipMode() { return require('./bongos/project-settings').resolveShipMode; },
476
476
  get describeShipMode() { return require('./bongos/project-settings').describeShipMode; },
477
+ // Tweak auto-pickup (task 1004677, ADR 0371 D3/D5): the switch and the monthly cap.
478
+ get AUTOPICKUP_MODES() { return require('./bongos/project-settings').AUTOPICKUP_MODES; },
479
+ get MAX_AUTOPICKUP_CAP() { return require('./bongos/project-settings').MAX_AUTOPICKUP_CAP; },
480
+ get parseAutopickup() { return require('./bongos/project-settings').parseAutopickup; },
481
+ get parseAutopickupCap() { return require('./bongos/project-settings').parseAutopickupCap; },
482
+ get resolveTweakAutopickup() { return require('./bongos/project-settings').resolveTweakAutopickup; },
483
+ get describeTweakAutopickup() { return require('./bongos/project-settings').describeTweakAutopickup; },
477
484
  },
478
485
  readConfigFileSync: instanceConfig.readConfigFileSync,
479
486
  configHome: instanceConfig.configHome,