@bongos/core 1.23.20 → 1.23.22

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 (73) hide show
  1. package/.bongos-core.json +137 -67
  2. package/.claude/skills/goal-create/SKILL.md +5 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +5 -0
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +87 -0
  9. package/docs/adr/0386-the-funding-wall-is-a-per-project-rule-claim-time-hold-by-default.md +37 -0
  10. package/docs/adr/0387-two-ledgers-credits-measure-work-money-stays-with-the-processor.md +39 -0
  11. package/docs/adr/0388-a-module-sale-is-split-by-the-project-the-buyer-pays-the-platform-fee.md +42 -0
  12. package/docs/adr/0392-ai-generation-runs-on-a-processor-held-balance-and-the-builders-own-keys.md +33 -0
  13. package/docs/adr/README.md +5 -0
  14. package/docs/api/openapi.json +59 -4
  15. package/docs/api-reference.md +7 -1
  16. package/docs/file-map.md +3 -0
  17. package/docs/module-api-changelog.md +4 -0
  18. package/docs/page-readings.json +1 -1
  19. package/modules/ai-studio/CLAUDE.md +15 -0
  20. package/modules/ai-studio/module.json +26 -0
  21. package/modules/ai-studio/routes/ai-studio.js +22 -0
  22. package/modules/copy-desk/chrome-pages.js +178 -0
  23. package/modules/copy-desk/page-data.js +34 -2
  24. package/modules/copy-desk/tests/copy_chrome_pages.mjs +127 -0
  25. package/modules/copy-desk/tests/copy_no_cms.mjs +1 -1
  26. package/modules/government/db.js +74 -0
  27. package/modules/government/routes/government.js +28 -18
  28. package/modules/grading/grader-subagent.js +30 -11
  29. package/modules/hall-ui/public/city.html +15 -15
  30. package/modules/lifecycle/db-analytics.js +10 -3
  31. package/modules/lifecycle/db-goals.js +30 -13
  32. package/modules/lifecycle/db-shared.js +9 -0
  33. package/modules/lifecycle/db-versions.js +21 -8
  34. package/modules/lifecycle/ship-assurance.js +4 -1
  35. package/modules/lifecycle/ship-card.js +8 -2
  36. package/modules/onboarding/howto-moments.js +8 -1
  37. package/modules/provisioning/starter-bundles.js +4 -2
  38. package/package-lock.json +2 -2
  39. package/package.json +1 -1
  40. package/release-notes.json +72 -0
  41. package/scripts/bongos/copy-apply.js +3 -1
  42. package/scripts/bongos/publish-manifest.js +1 -0
  43. package/scripts/bongos/strand-watch.js +106 -10
  44. package/scripts/bongos/tweak-renders.js +9 -2
  45. package/src/bongos/module-scope-map.js +4 -0
  46. package/src/module-api.js +1 -1
  47. package/src/platform-server.js +27 -1
  48. package/tests/batched_writes_parity.mjs +340 -0
  49. package/tests/conductor_main_e2e.mjs +4 -0
  50. package/tests/copy_desk_page_docx.mjs +3 -3
  51. package/tests/copy_desk_page_editor.mjs +6 -6
  52. package/tests/copy_desk_page_next.mjs +3 -2
  53. package/tests/copy_desk_page_reads.mjs +2 -1
  54. package/tests/copy_desk_page_submit.mjs +30 -2
  55. package/tests/fixtures/per-row-loops-legacy.cjs +245 -0
  56. package/tests/goal_archive_disposition.mjs +5 -5
  57. package/tests/government_routes.mjs +145 -22
  58. package/tests/grader.mjs +31 -0
  59. package/tests/hall_city.mjs +8 -0
  60. package/tests/hook_shell_tools.mjs +8 -0
  61. package/tests/hooks_drain.mjs +1 -1
  62. package/tests/host_topology_skips.mjs +2 -0
  63. package/tests/howto_moments.mjs +17 -0
  64. package/tests/module_loader.mjs +1 -1
  65. package/tests/module_skills_hook.mjs +69 -0
  66. package/tests/profile_role_stats.mjs +2 -1
  67. package/tests/public_landing_projects.mjs +37 -0
  68. package/tests/session_start_freshness.mjs +66 -1
  69. package/tests/shell_hooks.mjs +209 -0
  70. package/tests/ship_assurance.mjs +28 -0
  71. package/tests/ship_card.mjs +20 -5
  72. package/tests/strand_watch.mjs +48 -0
  73. package/tests/version_close_route.mjs +1 -1
@@ -860,12 +860,19 @@ async function tallyPeerVotes() {
860
860
  const karmaRows = [];
861
861
  for (const [builderId, net] of netByBuilder) {
862
862
  if (net === 0) continue; // no net signal → no karma row, but still mark tallied
863
+ karmaRows.push({ builder_id: builderId, delta: net });
864
+ }
865
+ // task 1004929: ONE insert for every credited builder, not one per builder.
866
+ // WITH ORDINALITY + ORDER BY keeps the karma_log ids in the order the loop
867
+ // inserted them (first vote seen first).
868
+ if (karmaRows.length) {
863
869
  await client.query(
864
870
  `INSERT INTO karma_log (builder_id, delta, reason, granted_by)
865
- VALUES ($1, $2, 'peer_vote_weekly', NULL)`,
866
- [builderId, net]
871
+ SELECT k.builder_id, k.delta, 'peer_vote_weekly', NULL::bigint
872
+ FROM unnest($1::bigint[], $2::int[]) WITH ORDINALITY AS k(builder_id, delta, ord)
873
+ ORDER BY k.ord`,
874
+ [karmaRows.map((r) => r.builder_id), karmaRows.map((r) => r.delta)]
867
875
  );
868
- karmaRows.push({ builder_id: builderId, delta: net });
869
876
  }
870
877
  if (voteIds.length) {
871
878
  await client.query(
@@ -16,7 +16,7 @@ const doneWhen = require('./done-when.js');
16
16
  const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
17
17
  const { nonTerminalSql, isTerminalStatus } = require('./task-status');
18
18
  const { maintenanceGoalTitle } = require('./goal-advisory.js');
19
- const { SMOKE_NOT_LIKE } = require('./db-shared.js');
19
+ const { SMOKE_NOT_LIKE, idKey } = require('./db-shared.js');
20
20
  const { executorFor, inTransaction } = require('./db-executor.js');
21
21
  // Task 1003660 — closing a goal cancels its pending invites + join requests.
22
22
  const { cancelPendingMembershipRequests } = require('./goal-close-sweep.js');
@@ -733,28 +733,43 @@ async function archiveGoalWithDispositions({ goalId, plan = [], reason }, deps =
733
733
  return { goal: goalRows[0], applied: [], alreadyArchived: true };
734
734
  }
735
735
 
736
- const applied = [];
737
- for (const step of plan) {
736
+ // task 1004929: every plan task is locked and read in ONE statement, and the
737
+ // active claims on them in one more, instead of two round trips per step. The
738
+ // locks are taken in id order (no deadlock against another batch), then the
739
+ // plan is walked in memory in PLAN order, so the applied list and the first
740
+ // error are what the per-step loop produced. `inGoal` tracks this call's own
741
+ // writes, so a task named twice reads the state the first step left behind.
742
+ const inGoal = new Map();
743
+ const claimedIds = new Set();
744
+ if (plan.length > 0) {
745
+ const ids = plan.map((step) => step.taskId);
738
746
  const { rows: tRows } = await client.query(
739
- `SELECT id, title, status FROM tasks WHERE id = $1 AND goal_id = $2 FOR UPDATE`,
740
- [step.taskId, goalId]
747
+ `SELECT id, title, status FROM tasks WHERE id = ANY($1::bigint[]) AND goal_id = $2 ORDER BY id FOR UPDATE`,
748
+ [ids, goalId]
741
749
  );
750
+ for (const row of tRows) inGoal.set(idKey(row.id), row);
751
+ const { rows: claimed } = await client.query(
752
+ `SELECT DISTINCT task_id FROM claims WHERE task_id = ANY($1::bigint[]) AND released_at IS NULL`, [ids]
753
+ );
754
+ for (const row of claimed) claimedIds.add(idKey(row.task_id));
755
+ }
756
+
757
+ const applied = [];
758
+ for (const step of plan) {
759
+ const task = inGoal.get(idKey(step.taskId));
742
760
  // The task left this goal (or shipped) while the caller was deciding. Not an
743
761
  // error: the disposition is moot, and the final re-count is what actually
744
762
  // guards the invariant. Recorded as 'skipped' so the response tells the
745
763
  // truth about what this call did rather than claiming a write it never made.
746
- if (!tRows[0]) { applied.push({ task_id: String(step.taskId), verb: step.verb, result: 'skipped_not_in_goal' }); continue; }
747
- if (isTerminalStatus(tRows[0].status)) {
748
- applied.push({ task_id: String(step.taskId), verb: step.verb, result: 'skipped_already_terminal', status: tRows[0].status });
764
+ if (!task) { applied.push({ task_id: String(step.taskId), verb: step.verb, result: 'skipped_not_in_goal' }); continue; }
765
+ if (isTerminalStatus(task.status)) {
766
+ applied.push({ task_id: String(step.taskId), verb: step.verb, result: 'skipped_already_terminal', status: task.status });
749
767
  continue;
750
768
  }
751
769
 
752
- const { rows: claimed } = await client.query(
753
- `SELECT id FROM claims WHERE task_id = $1 AND released_at IS NULL LIMIT 1`, [step.taskId]
754
- );
755
- if (claimed[0]) {
770
+ if (claimedIds.has(idKey(step.taskId))) {
756
771
  throw Object.assign(new Error('task has an active claim'), {
757
- code: 'TASK_HAS_ACTIVE_CLAIM', taskId: String(step.taskId), taskTitle: tRows[0].title,
772
+ code: 'TASK_HAS_ACTIVE_CLAIM', taskId: String(step.taskId), taskTitle: task.title,
758
773
  });
759
774
  }
760
775
 
@@ -766,6 +781,7 @@ async function archiveGoalWithDispositions({ goalId, plan = [], reason }, deps =
766
781
  WHERE id = $1`,
767
782
  [step.taskId, `Abandoned: ${reason}`]
768
783
  );
784
+ task.status = 'abandoned';
769
785
  applied.push({ task_id: String(step.taskId), verb: 'abandon', result: 'abandoned' });
770
786
  } else {
771
787
  const { rows: dest } = await client.query(
@@ -780,6 +796,7 @@ async function archiveGoalWithDispositions({ goalId, plan = [], reason }, deps =
780
796
  });
781
797
  }
782
798
  await client.query(`UPDATE tasks SET goal_id = $2, updated_at = now() WHERE id = $1`, [step.taskId, step.toGoalId]);
799
+ if (idKey(step.toGoalId) !== idKey(goalId)) inGoal.delete(idKey(step.taskId));
783
800
  applied.push({ task_id: String(step.taskId), verb: 'move', result: 'moved', to_goal_id: String(step.toGoalId) });
784
801
  }
785
802
  }
@@ -104,10 +104,19 @@ async function mostRecentClaimHolderId(client, taskId) {
104
104
  return rows[0]?.builder_id ?? null;
105
105
  }
106
106
 
107
+ // One spelling of a bigint id as a Map key (task 1004929). pg returns bigint
108
+ // columns as strings while a plan may carry numbers, so a batched locked read
109
+ // (the goal archive, the version close) matches its rows back to the plan steps
110
+ // through this on both sides. A value BigInt cannot read keeps its own string.
111
+ function idKey(v) {
112
+ try { return BigInt(v).toString(); } catch { return String(v); }
113
+ }
114
+
107
115
  module.exports = {
108
116
  SMOKE_NOT_LIKE,
109
117
  gradePassCount,
110
118
  gradeQualityWhere,
119
+ idKey,
111
120
  median,
112
121
  mostRecentClaimHolderId,
113
122
  sealSessionLog,
@@ -14,7 +14,7 @@ const api = require('../../src/module-api');
14
14
  const { pool, withTx } = api;
15
15
  const doneWhen = require('./done-when.js');
16
16
  const { nonTerminalSql } = require('./task-status');
17
- const { SMOKE_NOT_LIKE } = require('./db-shared.js');
17
+ const { SMOKE_NOT_LIKE, idKey } = require('./db-shared.js');
18
18
  const { executorFor, inTransaction } = require('./db-executor.js');
19
19
  // db-goals.js does NOT require this file, so this direction adds no require cycle
20
20
  // (fitness pins the count). createVersion needs it to seed a version's FIRST goal
@@ -372,18 +372,29 @@ async function closeVersion({ versionId, plan = [], reason, planningVersionId =
372
372
  });
373
373
  }
374
374
 
375
- const applied = [];
376
- for (const step of plan) {
375
+ // task 1004929: every plan goal is locked and read in ONE statement instead of
376
+ // one round trip per step. Locks are taken in id order (no deadlock against
377
+ // another batch); the plan is then walked in memory in PLAN order, so the
378
+ // applied list is what the per-step loop produced. Each step's own archive is
379
+ // mirrored into `onVersion`, so a goal named twice reads 'archived' second time.
380
+ const onVersion = new Map();
381
+ if (plan.length > 0) {
377
382
  const { rows: gRows } = await client.query(
378
- `SELECT id, title, status FROM goals WHERE id = $1 AND version_id = $2 FOR UPDATE`,
379
- [step.goalId, versionId]
383
+ `SELECT id, title, status FROM goals WHERE id = ANY($1::bigint[]) AND version_id = $2 ORDER BY id FOR UPDATE`,
384
+ [plan.map((step) => step.goalId), versionId]
380
385
  );
386
+ for (const row of gRows) onVersion.set(idKey(row.id), row);
387
+ }
388
+
389
+ const applied = [];
390
+ for (const step of plan) {
391
+ const goal = onVersion.get(idKey(step.goalId));
381
392
  // The goal moved or closed itself while the caller was deciding. Not an
382
393
  // error — the final re-count is what guards the invariant — but recorded so
383
394
  // the response says what this call actually did.
384
- if (!gRows[0]) { applied.push({ goal_id: String(step.goalId), verb: step.verb, result: 'skipped_not_on_version' }); continue; }
385
- if (gRows[0].status !== 'open') {
386
- applied.push({ goal_id: String(step.goalId), verb: step.verb, result: 'skipped_already_closed', status: gRows[0].status });
395
+ if (!goal) { applied.push({ goal_id: String(step.goalId), verb: step.verb, result: 'skipped_not_on_version' }); continue; }
396
+ if (goal.status !== 'open') {
397
+ applied.push({ goal_id: String(step.goalId), verb: step.verb, result: 'skipped_already_closed', status: goal.status });
387
398
  continue;
388
399
  }
389
400
 
@@ -410,6 +421,7 @@ async function closeVersion({ versionId, plan = [], reason, planningVersionId =
410
421
  [step.goalId, `Abandoned: ${reason}`]
411
422
  );
412
423
  await client.query(`UPDATE goals SET status = 'archived', updated_at = now() WHERE id = $1`, [step.goalId]);
424
+ Object.assign(goal, { status: 'archived' }); // mirror the write above for a goal named twice
413
425
  await cancelPendingMembershipRequests(client, step.goalId);
414
426
  applied.push({ goal_id: String(step.goalId), verb: 'abandon', result: 'archived', tasks_abandoned: killed.length });
415
427
  } else {
@@ -502,6 +514,7 @@ async function closeVersion({ versionId, plan = [], reason, planningVersionId =
502
514
  // on the way out rather than being special-cased around it (the brief's
503
515
  // explicit ask).
504
516
  await client.query(`UPDATE goals SET status = 'archived', updated_at = now() WHERE id = $1`, [step.goalId]);
517
+ Object.assign(goal, { status: 'archived' }); // mirror the write above for a goal named twice
505
518
  await cancelPendingMembershipRequests(client, step.goalId);
506
519
 
507
520
  applied.push({
@@ -194,12 +194,15 @@ function githubAccessCause(errors) {
194
194
  // best-effort by design: insertAuditLog uses the module pool rather than the
195
195
  // ship's transaction client, and an audit miss must never roll back a landed ship
196
196
  // (the posture src/bongos/middleware/audit.js already takes).
197
+ // method is 'POST' — the ship route's own verb. audit_log's CHECK admits only
198
+ // POST/PATCH/PUT/DELETE (migration 033), so the old 'SHIP' failed every write
199
+ // and the record never existed (task 1004879).
197
200
  async function recordShipAssurance({ insertAuditLog, taskId, builderId, assurance, log }) {
198
201
  try {
199
202
  await insertAuditLog({
200
203
  builderId,
201
204
  route: `/tasks/${taskId}/ship`,
202
- method: 'SHIP',
205
+ method: 'POST',
203
206
  requestBodyRedacted: { ship_assurance: assurance },
204
207
  responseStatus: 200,
205
208
  surface: 'lifecycle',
@@ -447,10 +447,15 @@ function buildCardModel(input = {}) {
447
447
  let deployText;
448
448
  let deployCommit = null;
449
449
  let hostedText = null;
450
+ // task 1004741: "live" only when a reading SAYS the live build carries this
451
+ // merge. Shipped means merged; a merge reaches no instance until a published
452
+ // release carries it and the pin moves (merge-reach.js).
453
+ let liveConfirmed = false;
450
454
  if (stage === 'shipped') {
451
455
  deployCommit = input.deploy && input.deploy.commit ? String(input.deploy.commit) : null;
452
456
  const reach = boundReading(input.deploy && input.deploy.reach);
453
457
  if (reach) {
458
+ liveConfirmed = reach.includes === true;
454
459
  // task 1004682: who sees this merge, in the one shared wording
455
460
  // (merge-reach.js) — the hub's live build as read, then the hosted rule.
456
461
  // A bare "live · <sha>" read as "every user has it", which is only true
@@ -458,7 +463,8 @@ function buildCardModel(input = {}) {
458
463
  // boundReading re-bounds it (host-shaped, hex, boolean) before printing.
459
464
  [deployText, hostedText] = whoSeesAMerge(reach);
460
465
  } else {
461
- deployText = deployCommit ? `live · ${deployCommit}` : 'live';
466
+ // No reading of the live build: say what is known (it merged), never "live".
467
+ deployText = `merged${deployCommit ? ` · ${deployCommit}` : ''} — not live until a published release carries it and the pin moves`;
462
468
  }
463
469
  } else if (stage === 'confirmed') {
464
470
  // task 1002541: this line used to be unconditional, driven by the STATUS
@@ -491,7 +497,7 @@ function buildCardModel(input = {}) {
491
497
 
492
498
  addFact('', changed);
493
499
  if (gradeText) addFact('grade', gradeText, { cls: gradeCls });
494
- if (stage === 'shipped') addFact('live', deployCommit, { mono: true });
500
+ if (stage === 'shipped') addFact(liveConfirmed ? 'live' : 'merged', deployCommit, { mono: true });
495
501
  // The reason can be a full sentence — too long for a fact chip, so the chip
496
502
  // carries the alarm and the reason rides in its tooltip (the Deploy row above
497
503
  // always shows it in full).
@@ -46,9 +46,16 @@ const REQUESTS = [
46
46
  ['show', new RegExp(String.raw`\b(?:show|open|reopen|see|view)\b.{0,24}\b${GUIDE}\b|\b${GUIDE}\b.{0,12}\bprogress\b`, 'i')],
47
47
  ];
48
48
 
49
+ // Only a prompt that IS the request counts (task 1004744): one short line. A pasted
50
+ // handoff or a long message that merely QUOTES "skip the tutorial" is about
51
+ // something else, and running `howto skip` on it would change the person's guide
52
+ // state unasked.
53
+ const REQUEST_MAX_CHARS = 100;
54
+
49
55
  // 'show' | 'next' | 'skip' | 'reset' when the prompt asks for the guide itself, else null.
50
56
  function guideRequest(prompt) {
51
- const p = typeof prompt === 'string' ? prompt : '';
57
+ const p = typeof prompt === 'string' ? prompt.trim() : '';
58
+ if (p.length > REQUEST_MAX_CHARS || /[\r\n]/.test(p)) return null;
52
59
  for (const [kind, re] of REQUESTS) if (re.test(p)) return kind;
53
60
  return null;
54
61
  }
@@ -78,8 +78,10 @@ const ALWAYS_ON_CORE = Object.freeze([
78
78
  // Render: it is turned on by the connect-Render step, never chosen in this picker, so it is
79
79
  // kept out of the opt-in catalog by the same list. `raise` (task 1004847) is the hub's own
80
80
  // investor page: its copy is Cloud Bongos's raise, so offering it to a new project would hand
81
- // that project someone else's fundraise (task 1004852).
82
- const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning', 'npm-release', 'render-deploy', 'raise']);
81
+ // that project someone else's fundraise (task 1004852). `ai-studio` (task 1004916, ADR 0392)
82
+ // bills AI generation through Cloud Bongos's own payment processor account, so it stays on
83
+ // the hub until a project can bring its own billing.
84
+ const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning', 'npm-release', 'render-deploy', 'raise', 'ai-studio']);
83
85
 
84
86
  // The OPT-IN catalog as the creation picker renders it (BV1.R20, task 1002339):
85
87
  // every module an owner may actually choose, in the order the picker lists them,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.23.20",
3
+ "version": "1.23.22",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.23.20",
9
+ "version": "1.23.22",
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.20",
3
+ "version": "1.23.22",
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",
@@ -10509,5 +10509,77 @@
10509
10509
  "id": "1002983",
10510
10510
  "text": "The Community home now shows a rising builder and the projects on the move this month, under the builders of the week, month and year."
10511
10511
  }
10512
+ ],
10513
+ "1.23.21": [
10514
+ {
10515
+ "id": "1004904",
10516
+ "text": "In Tweak Mode, the shared top bar, navigation, footer and skip link no longer clutter every page; each is now its own page to review once."
10517
+ }
10518
+ ],
10519
+ "1.23.22": [
10520
+ {
10521
+ "id": "1004916",
10522
+ "text": "Groundwork for letting artists generate images with AI: the design for pricing and key storage is written down and the new module has its place in the project."
10523
+ },
10524
+ {
10525
+ "id": "1004742",
10526
+ "text": "Asking the session guide for the next tutorial step a second time now shows just that step, instead of repeating every earlier reminder."
10527
+ },
10528
+ {
10529
+ "id": "1004929",
10530
+ "text": "Four database jobs that used to ask the database one question per item now ask once for the whole batch: adding a recommended set of ranks, archiving a goal, closing a version, and the weekly karma count. Results are the same,"
10531
+ },
10532
+ {
10533
+ "id": "1004944",
10534
+ "text": "The goal-planning guide no longer tells people to send a setting the server rejects, and now reminds them to name each task's role."
10535
+ },
10536
+ {
10537
+ "id": "1004931",
10538
+ "text": "AI sessions run faster. The safety checks that run around every command the AI types used to start four or five separate small programs each time; they now share two, cutting that overhead about in half with the same checks an"
10539
+ },
10540
+ {
10541
+ "id": "1004909",
10542
+ "text": "How module sales are shared is now decided. The buyer pays a small platform fee on top of the price (processing cost plus 1 percent), so builders lose nothing to it. Each project sets how its module money is split between the"
10543
+ },
10544
+ {
10545
+ "id": "1004741",
10546
+ "text": "The finished-task card no longer says a change is \"live\" just because it was merged. It now says \"merged\" until the live site is actually confirmed to be running it, so nobody acts on work that has not reached the site yet."
10547
+ },
10548
+ {
10549
+ "id": "1004928",
10550
+ "text": "AI sessions now start several seconds faster: the stuck-work check reuses its recent answer instead of asking the server every time, and the check for newer code no longer waits on the network while still saying how up to date"
10551
+ },
10552
+ {
10553
+ "id": "1004925",
10554
+ "text": "Pages load a little faster: the city page no longer waits on each of its scripts in turn, and returning visitors to the landing keep its styles and scripts for a year instead of fetching them again every few hours."
10555
+ },
10556
+ {
10557
+ "id": "1004932",
10558
+ "text": "Quality reviews now cost less. Each reviewer used to be handed up to twice as much file text as it needs; it now gets half, starting with the files the change is mostly about, and can still open the rest itself."
10559
+ },
10560
+ {
10561
+ "id": "1003180",
10562
+ "text": "Credits and money are now two separate records. Credits keep measuring who did how much work, and money lives in its own record tied to the payment processor, so a payout can always be explained from one place. Old credit reco"
10563
+ },
10564
+ {
10565
+ "id": "1004744",
10566
+ "text": "Pasting a long message that happens to mention \"skip the tutorial\" no longer switches off your how-to guide. Only a short request that actually asks for it does."
10567
+ },
10568
+ {
10569
+ "id": "1003181",
10570
+ "text": "Each project now decides what happens when it runs short of money: refuse new work, refuse completion, or carry on and record what it owes. A project that picks nothing gets the safest default, which stops a builder from start"
10571
+ },
10572
+ {
10573
+ "id": "1004915",
10574
+ "text": "Artists now get /tweak (and any other enabled module skill) automatically when a session starts, instead of needing a manual copy step. Other builders see no change."
10575
+ },
10576
+ {
10577
+ "id": "1004879",
10578
+ "text": "Every ship now keeps its record of why it was allowed to land. Before, that record was silently thrown away on every ship because the database refused how it was labelled."
10579
+ },
10580
+ {
10581
+ "id": "1004905",
10582
+ "text": "The money plan is now decided on paper: a licensed payment processor holds all user and project money, never Bongos. Credits stay a per-project measure of contribution and are never cash. Each project sets what its work pays a"
10583
+ }
10512
10584
  ]
10513
10585
  }
@@ -62,6 +62,7 @@ const ci = require('./copy-inventory.js');
62
62
  const proposals = require('../../modules/copy-desk/proposals.js');
63
63
  // The page tweak format's one reader and writer (ADR 0341 D4), for --batch.
64
64
  const pages = require('../../modules/copy-desk/pages.js');
65
+ const chromePages = require('../../modules/copy-desk/chrome-pages.js');
65
66
  const { apiCall, arg, hasFlag, cliExit } = require('./cli-lib.js');
66
67
 
67
68
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
@@ -559,7 +560,8 @@ function rereadStalePages({ spawn = spawnSync } = {}) {
559
560
  function readingFor(pageId, { readFile = (p) => fs.readFileSync(p, 'utf8') } = {}) {
560
561
  try {
561
562
  const doc = JSON.parse(readFile(path.join(REPO_ROOT, 'docs', 'page-readings.json')));
562
- return (doc.pages || []).find((p) => p.id === pageId) || null;
563
+ // A shared-chrome page (task 1004904) exists only in the derived reading.
564
+ return (chromePages.splitReadings(doc).readings.pages || []).find((p) => p.id === pageId) || null;
563
565
  } catch { return null; }
564
566
  }
565
567
 
@@ -76,6 +76,7 @@ const PUBLISH_ALLOWLIST = [
76
76
  'modules/agents/', // the declarative agent registry + spawn seam (goal 1000038) — CORE: its own manifest declares maintenance.status 'core-maintained', and it consumes the core `grade` port, so a released core without it cannot run the MAS machinery the methodology describes. default:false — shipped is not enabled (ADR 0083), exactly like provisioning + platform-identity below. Omitted at module-creation time (tasks 1002485/1002486): the omission was invisible until tests/agents_spawn.mjs became the first PUBLISHABLE file to import it and the R89 boundary guard caught it (task 1002489).
77
77
  'modules/npm-release/', // the deploy page's "where is the work" + version history (task 1002622). default:false — on only in the hall that publishes the core. MUST be here: modules/hall-ui/ ships as one subtree, so a released core without this carries deploy.js's loader and the #nr-work mounts with no routes behind them — the copy-desk asymmetry above.
78
78
  'modules/render-deploy/', // the deploy page of a project whose app deploys through Render (task 1004270). default:false. MUST be here for the npm-release reason: modules/hall-ui/ ships as one subtree, so a released core without it carries deploy.js's loader for #rd-panel with no routes behind it.
79
+ 'modules/ai-studio/', // AI generation for artists (task 1004916, ADR 0392): keys, prepaid balance, generate. default:false.
79
80
  'modules/raise/', // the /raise investor page (task 1004847, ADR 0384) — ships in core, enabled ONLY on the hub, like platform-identity. Owner decision 2026-10-25: the whole page, its copy and starting numbers included, lives in the repo so builders can contribute to it, accepting that it publishes with the core. Secrets never do: the investor password is RAISE_PASSWORD_HASH in the instance env. default:false.
80
81
  'modules/platform-identity/', // hub identity provider (ADR 0141) — ships in core, enabled ONLY on the hub (cloudbongos.com). MUST be here or the hub's enabledModules() throws "unknown module" and provision.js's federation load MODULE_NOT_FOUNDs (task 1002235, same rationale as provisioning above). default:false — shipped ≠ enabled; a vanilla/self-hosted instance never turns it on.
81
82
  // --- neutral (customer-0) config starters + their schemas (the filled-in *.json twins are denied) ---
@@ -31,7 +31,8 @@
31
31
  //
32
32
  // Usage:
33
33
  // node scripts/bongos/strand-watch.js # human-readable report
34
- // node scripts/bongos/strand-watch.js --line # ONE line, or nothing; always exit 0
34
+ // node scripts/bongos/strand-watch.js --line # ONE line, or nothing; always exit 0 (cached; see linePlan)
35
+ // node scripts/bongos/strand-watch.js --refresh-line # refresh that cache; prints nothing (spawned detached by --line)
35
36
  // node scripts/bongos/strand-watch.js --json # machine shape
36
37
  // node scripts/bongos/strand-watch.js --hours N # override the `completed` threshold
37
38
  //
@@ -273,6 +274,77 @@ function report(r) {
273
274
  console.log('');
274
275
  }
275
276
 
277
+ // ---- the SessionStart line, kept off the critical path (task 1004928) --------
278
+ //
279
+ // THE MEASUREMENT. The credit-burn audit timed this hook at 8.2 s — the slowest SessionStart
280
+ // producer, and so the floor for every session start. The shared cache it used (task 1003689)
281
+ // lived 60 s, which only helps sessions opened together; the next session an hour later paid
282
+ // the whole ledger read again, up to 8 s per call (and the held-tweak pass can add another).
283
+ //
284
+ // THE FIX. A strand is measured in hours (one hour for `confirmed`, twelve for `completed`), so
285
+ // an answer a few minutes old is as true as a fresh one. So: keep the answer LINE_FRESH_MS and
286
+ // print it at once; past that, up to LINE_STALE_MAX_MS, print it at once LABELLED WITH ITS AGE
287
+ // and refresh the cache in a detached child for the next session; with nothing usable, read the
288
+ // ledger but give up after LINE_DEADLINE_MS. An undetermined read is never cached — a stored
289
+ // silence would pass for a clean board for the whole fresh window.
290
+ const LINE_CACHE_KEY = 'strand-watch-line';
291
+ const LINE_FRESH_MS = 15 * 60 * 1000;
292
+ const LINE_STALE_MAX_MS = 2 * 60 * 60 * 1000;
293
+ const LINE_DEADLINE_MS = 4000;
294
+
295
+ // PURE — what to do with the cached record: 'fresh' (print it), 'stale' (print it with its age
296
+ // and refresh in the background) or 'miss' (read the ledger now, time-boxed). A record from the
297
+ // future means the clock moved, and is treated as a miss rather than trusted.
298
+ function linePlan(record, nowMs, { freshMs = LINE_FRESH_MS, staleMaxMs = LINE_STALE_MAX_MS } = {}) {
299
+ if (!record || typeof record.at !== 'number' || !Number.isFinite(nowMs)) return 'miss';
300
+ const age = nowMs - record.at;
301
+ if (age < 0) return 'miss';
302
+ if (age < freshMs) return 'fresh';
303
+ if (age < staleMaxMs) return 'stale';
304
+ return 'miss';
305
+ }
306
+
307
+ // PURE — the line as printed from a stale cache: the same words, plus how old they are, so a
308
+ // reader can weigh them. A clean (null) answer stays silent: there is nothing to label.
309
+ function labelStale(text, ageMs) {
310
+ if (!text) return null;
311
+ return `${text} (Checked ${humanAge(ageMs)} ago; refreshing for the next session.)`;
312
+ }
313
+
314
+ // The ledger read for the line, bounded as a WHOLE (check() bounds each call, not the sum).
315
+ // Throws on an undetermined read so probe-cache stores nothing.
316
+ async function produceLine(opts, deadlineMs) {
317
+ let timer;
318
+ const deadline = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('deadline')), deadlineMs); });
319
+ try {
320
+ const r = await Promise.race([check({ ...opts, timeoutMs: Math.min(deadlineMs, DEFAULT_TIMEOUT_MS) }), deadline]);
321
+ if (!r || r.undetermined) throw new Error('undetermined');
322
+ return line(r);
323
+ } finally { clearTimeout(timer); }
324
+ }
325
+
326
+ function readLineRecord() {
327
+ try {
328
+ const { cachePath } = require('./probe-cache.js');
329
+ return JSON.parse(require('node:fs').readFileSync(cachePath(LINE_CACHE_KEY), 'utf8'));
330
+ } catch { return null; }
331
+ }
332
+
333
+ // Detached + unref'd + hidden, so the session start never waits on it (windowsHide: a detached
334
+ // child on Windows otherwise flashes a console window). --hours is passed through so the
335
+ // refreshed answer uses the same threshold.
336
+ function spawnRefresh() {
337
+ try {
338
+ const args = [__filename, '--refresh-line'];
339
+ const hours = arg('--hours');
340
+ if (hours) args.push('--hours', String(hours));
341
+ const child = require('node:child_process').spawn(process.execPath, args, {
342
+ detached: true, stdio: 'ignore', windowsHide: true, cwd: process.cwd(),
343
+ });
344
+ child.unref();
345
+ } catch { /* the next session reads the ledger itself */ }
346
+ }
347
+
276
348
  async function main() {
277
349
  const hours = Number(arg('--hours'));
278
350
  const opts = Number.isFinite(hours) && hours > 0 ? { completedStaleMs: hours * 3600000 } : {};
@@ -285,16 +357,39 @@ async function main() {
285
357
  // deploy-divergence's identical comment).
286
358
  if (hasFlag('--line')) {
287
359
  try {
288
- // SHARED ACROSS CONCURRENT SESSION STARTS (task 1003689). Four sessions opened
289
- // within three seconds — one worktree per claim, the normal pattern here — used
290
- // to make this same call four times and ALL of them timed out, which produces no
291
- // line at all. Silence from a truth-teller is indistinguishable from 'nothing to
292
- // report', so the brief was lost without a symptom. The first session to ask now
293
- // answers for the rest.
360
+ // SHARED ACROSS SESSION STARTS (task 1003689, widened by task 1004928). Four sessions
361
+ // opened within three seconds — one worktree per claim, the normal pattern here — used
362
+ // to make this same call four times and ALL of them timed out, which produces no line
363
+ // at all. The first session to ask answers for the rest; see linePlan for how long.
364
+ const rec = readLineRecord();
365
+ const plan = linePlan(rec, Date.now());
366
+ if (plan === 'fresh') {
367
+ if (rec.value) console.log(rec.value);
368
+ } else if (plan === 'stale') {
369
+ const one = labelStale(rec.value, Date.now() - rec.at);
370
+ if (one) console.log(one);
371
+ spawnRefresh();
372
+ } else {
373
+ const { cached } = require('./probe-cache.js');
374
+ const { value: one, from } = await cached(LINE_CACHE_KEY, () => produceLine(opts, LINE_DEADLINE_MS), { ttlMs: LINE_FRESH_MS });
375
+ // A sibling holds the lock and the cache handed back its last answer, however old:
376
+ // say how old, so a long-expired line is never passed off as current.
377
+ const shown = from === 'stale-while-locked' && rec && typeof rec.at === 'number'
378
+ ? labelStale(one, Date.now() - rec.at) : one;
379
+ if (shown) console.log(shown);
380
+ }
381
+ } catch { /* no session, offline, unreadable, past the deadline — all silence */ }
382
+ process.exitCode = 0;
383
+ return;
384
+ }
385
+
386
+ // --refresh-line: the detached child spawnRefresh starts. ttlMs 0 forces a fresh read; a
387
+ // sibling already refreshing holds the lock, and then this child simply stops. Prints nothing.
388
+ if (hasFlag('--refresh-line')) {
389
+ try {
294
390
  const { cached } = require('./probe-cache.js');
295
- const { value: one } = await cached('strand-watch-line', async () => line(await check(opts)));
296
- if (one) console.log(one);
297
- } catch { /* no session, offline, unreadable — all silence */ }
391
+ await cached(LINE_CACHE_KEY, () => produceLine(opts, DEFAULT_TIMEOUT_MS * 2), { ttlMs: 0 });
392
+ } catch { /* the next session reads the ledger itself */ }
298
393
  process.exitCode = 0;
299
394
  return;
300
395
  }
@@ -311,5 +406,6 @@ if (require.main === module) {
311
406
 
312
407
  module.exports = {
313
408
  check, line, report, classifyStrand, rankStrands, humanAge,
409
+ linePlan, labelStale, produceLine, LINE_FRESH_MS, LINE_STALE_MAX_MS, LINE_DEADLINE_MS,
314
410
  CONFIRMED_STALE_MS, COMPLETED_STALE_MS, STRAND_REASON_PREFIX, WATCHED, PAGE_LIMIT,
315
411
  };
@@ -87,10 +87,17 @@ function slotFilesIn(dir, { exists = (p) => fs.existsSync(p) } = {}) {
87
87
 
88
88
  // ---- the browser half --------------------------------------------------------
89
89
 
90
+ // The inventory as Tweak Mode sees it, shared-chrome pages included (task 1004904):
91
+ // the committed file does not list them, so planning from it alone is unknown_page.
92
+ function derivedInventory() {
93
+ const r = require('../../modules/copy-desk/page-data.js').loadInventory();
94
+ return r.ok ? r.data : { pages: [] };
95
+ }
96
+
90
97
  async function shoot({ pageId, phase, out }) {
91
98
  const reader = require('./page-reader.js');
92
99
  const kit = require('../../modules/ui-design/kit/lib');
93
- const inventory = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, reader.INVENTORY_REL), 'utf8'));
100
+ const inventory = derivedInventory();
94
101
  const planned = planForPage(pageId, { inventory, planRenders: reader.planRenders });
95
102
  if (!planned.ok) { console.error(`tweak-renders: ${planned.code} (${pageId})`); return 2; }
96
103
  const { page, plan } = planned;
@@ -172,7 +179,7 @@ async function main(args) {
172
179
  if (!pageId) { console.error('usage: node scripts/bongos/tweak-renders.js shoot --page <id> --phase before|after --out <dir>'); return 2; }
173
180
  if (cmd === 'plan') {
174
181
  const reader = require('./page-reader.js');
175
- const inventory = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, reader.INVENTORY_REL), 'utf8'));
182
+ const inventory = derivedInventory();
176
183
  const planned = planForPage(pageId, { inventory, planRenders: reader.planRenders });
177
184
  if (!planned.ok) { console.error(`tweak-renders: ${planned.code} (${pageId})`); return 2; }
178
185
  for (const phase of taskVisuals.TWEAK_RENDER_PHASES) for (const s of shotsFor(phase, planned.plan)) console.log(`${s.slot}\t${s.width}px\t${s.renderMode}\t${planned.plan.url}`);
@@ -182,6 +182,10 @@ const MODULE_GLOBS = {
182
182
  'page-notes': [
183
183
  'modules/page-notes/',
184
184
  ],
185
+ // AI generation for artists (task 1004916, ADR 0392): keys, prepaid balance, generate.
186
+ 'ai-studio': [
187
+ 'modules/ai-studio/',
188
+ ],
185
189
  // The /raise investor page (task 1004847, ADR 0384): hub-only.
186
190
  raise: [
187
191
  'modules/raise/',
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.20'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.23.22'; // 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');