@bongos/core 1.23.21 → 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 (61) hide show
  1. package/.bongos-core.json +117 -57
  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 +2 -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/government/db.js +74 -0
  23. package/modules/government/routes/government.js +28 -18
  24. package/modules/grading/grader-subagent.js +30 -11
  25. package/modules/hall-ui/public/city.html +15 -15
  26. package/modules/lifecycle/db-analytics.js +10 -3
  27. package/modules/lifecycle/db-goals.js +30 -13
  28. package/modules/lifecycle/db-shared.js +9 -0
  29. package/modules/lifecycle/db-versions.js +21 -8
  30. package/modules/lifecycle/ship-assurance.js +4 -1
  31. package/modules/lifecycle/ship-card.js +8 -2
  32. package/modules/onboarding/howto-moments.js +8 -1
  33. package/modules/provisioning/starter-bundles.js +4 -2
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/release-notes.json +66 -0
  37. package/scripts/bongos/publish-manifest.js +1 -0
  38. package/scripts/bongos/strand-watch.js +106 -10
  39. package/src/bongos/module-scope-map.js +4 -0
  40. package/src/module-api.js +1 -1
  41. package/src/platform-server.js +27 -1
  42. package/tests/batched_writes_parity.mjs +340 -0
  43. package/tests/conductor_main_e2e.mjs +4 -0
  44. package/tests/fixtures/per-row-loops-legacy.cjs +245 -0
  45. package/tests/goal_archive_disposition.mjs +5 -5
  46. package/tests/government_routes.mjs +145 -22
  47. package/tests/grader.mjs +31 -0
  48. package/tests/hall_city.mjs +8 -0
  49. package/tests/hook_shell_tools.mjs +8 -0
  50. package/tests/hooks_drain.mjs +1 -1
  51. package/tests/host_topology_skips.mjs +2 -0
  52. package/tests/howto_moments.mjs +17 -0
  53. package/tests/module_loader.mjs +1 -1
  54. package/tests/module_skills_hook.mjs +69 -0
  55. package/tests/public_landing_projects.mjs +37 -0
  56. package/tests/session_start_freshness.mjs +66 -1
  57. package/tests/shell_hooks.mjs +209 -0
  58. package/tests/ship_assurance.mjs +28 -0
  59. package/tests/ship_card.mjs +20 -5
  60. package/tests/strand_watch.mjs +48 -0
  61. package/tests/version_close_route.mjs +1 -1
@@ -434,40 +434,50 @@ module.exports = function buildGovernmentRouter() {
434
434
 
435
435
  // rank: perm government.manage — add a RECOMMENDED RANK SET (task 1004400):
436
436
  // the custom ranks recommendations.js names for a project type and size, each
437
- // with its grants. It is the create form and the grant toggle run in a loop,
438
- // under the same checks: a rank that already exists is LEFT ALONE (never
437
+ // with its grants. It does what the create form and the grant toggle do, for
438
+ // every rank in the set, under the same checks: a rank that already exists is LEFT ALONE (never
439
439
  // re-granted, never relabelled — an owner may have edited it), and every grant
440
440
  // is re-checked with catalog.isGrantableTo, so a set can never write what the
441
441
  // toggle would refuse. Re-applying is safe; the reply says what was added.
442
+ //
443
+ // task 1004929: the whole set is written by db.applyRankSet in ONE transaction
444
+ // and three statements, instead of a read + insert + one insert per grant for
445
+ // every rank. A rank this route creates is always a CUSTOM rank (is_system
446
+ // false, no seeded_rank — what createRank returns), so each grant is screened
447
+ // against that shape before the write, exactly as the toggle would screen it.
448
+ // A failure now writes nothing, so a 500 reports empty created/skipped lists.
442
449
  router.post('/government/rank-sets/apply', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
443
450
  if (validateOrRespond(req, res, {
444
451
  type: { type: 'string', maxLength: 32 },
445
452
  headcount: { required: true, type: 'integer', min: 0, max: 100000 },
446
453
  })) return;
447
454
  const set = recommendations.recommendedRankSet(req.body.type, req.body.headcount);
448
- const created = [];
449
- const skipped = [];
455
+ const customRank = { is_system: false, seeded_rank: null };
450
456
  try {
457
+ const granted = await db.applyRankSet({
458
+ createdBy: req.builder.id,
459
+ ranks: set.ranks.map((r) => ({
460
+ rankKey: r.rank_key,
461
+ label: r.label,
462
+ description: r.description,
463
+ permissionKeys: r.permissions.filter((key) => catalog.byKey(key) && catalog.isGrantableTo(key, customRank)),
464
+ })),
465
+ });
466
+ const created = [];
467
+ const skipped = [];
451
468
  for (const r of set.ranks) {
452
- if (await db.getRankByKey(r.rank_key)) { skipped.push({ rank_key: r.rank_key, label: r.label }); continue; }
453
- let rank;
454
- try {
455
- rank = await db.createRank({ rankKey: r.rank_key, label: r.label, description: r.description, createdBy: req.builder.id });
456
- } catch (err) {
457
- if (err && err.code === '23505') { skipped.push({ rank_key: r.rank_key, label: r.label }); continue; }
458
- throw err;
459
- }
460
- const granted = [];
461
- for (const key of r.permissions) {
462
- if (!catalog.byKey(key) || !catalog.isGrantableTo(key, rank)) continue;
463
- if (await db.grantRankPermission({ rankKey: r.rank_key, permissionKey: key })) granted.push(key);
469
+ // delete-on-read: a key named twice in a set is created once, then skipped.
470
+ if (granted.has(r.rank_key)) {
471
+ created.push({ rank_key: r.rank_key, label: r.label, permissions: granted.get(r.rank_key) });
472
+ granted.delete(r.rank_key);
473
+ } else {
474
+ skipped.push({ rank_key: r.rank_key, label: r.label });
464
475
  }
465
- created.push({ rank_key: r.rank_key, label: r.label, permissions: granted });
466
476
  }
467
477
  res.json({ ok: true, type: set.type, size: set.size, created, skipped });
468
478
  } catch (err) {
469
479
  log.error('[gds] POST /government/rank-sets/apply', err);
470
- res.fail('rank_set_apply_failed', { status: 500, message: 'internal error', details: { created, skipped } });
480
+ res.fail('rank_set_apply_failed', { status: 500, message: 'internal error', details: { created: [], skipped: [] } });
471
481
  }
472
482
  });
473
483
 
@@ -183,18 +183,23 @@ function runSubagent({ prompt, opts = {} }) {
183
183
  // agentic Read turns re-opening the same changed files. Read them ONCE here
184
184
  // and hand every worker the post-change contents inline (fenced, untrusted),
185
185
  // so a worker's Read tool is only needed for files beyond the bundle.
186
+ //
187
+ // task 1004932: the bundle rides in EVERY worker prompt, so its total is paid
188
+ // once per worker; 120k chars made it the largest fixed input of a grade.
186
189
  const CONTEXT_BUNDLE_MAX_FILES = 24;
187
- const CONTEXT_BUNDLE_MAX_TOTAL_CHARS = 120_000;
190
+ const CONTEXT_BUNDLE_MAX_TOTAL_CHARS = 60_000;
188
191
  const CONTEXT_BUNDLE_MAX_FILE_CHARS = 16_000;
189
192
  // Binary-ish extensions never worth inlining (the diff already names them).
190
193
  const CONTEXT_BUNDLE_SKIP_RE = /\.(?:png|jpe?g|gif|webp|ico|svgz|woff2?|ttf|eot|zip|gz|tgz|tar|pdf|mp[34]|wav|ogg|bin|exe|dll|node)$/i;
191
194
 
192
- // Added lines per file, from the unified diff. Only lines long enough to be a
193
- // meaningful fingerprint (a bare `}` or `});` matches anywhere) — used to prove
194
- // a truncated bundle prefix actually SHOWS the change (task 1002905).
195
+ // One pass over a unified diff, per file: how many lines it adds (a relevance
196
+ // weight), and the added lines long enough to be a meaningful fingerprint (a bare
197
+ // `}` or `});` matches anywhere) — used to prove a truncated bundle prefix
198
+ // actually SHOWS the change (task 1002905).
195
199
  const BUNDLE_EVIDENCE_MIN_CHARS = 12;
196
- function diffAddedLinesByFile(diff) {
197
- const byFile = new Map();
200
+ function scanDiff(diff) {
201
+ const evidence = new Map();
202
+ const added = new Map();
198
203
  let cur = null;
199
204
  let prevWasOldHeader = false;
200
205
  for (const line of String(diff || '').split('\n')) {
@@ -209,12 +214,26 @@ function diffAddedLinesByFile(diff) {
209
214
  prevWasOldHeader = line.startsWith('--- ');
210
215
  if (prevWasOldHeader || line.startsWith('diff --git')) continue;
211
216
  if (!cur || !line.startsWith('+')) continue;
217
+ added.set(cur, (added.get(cur) || 0) + 1);
212
218
  const t = line.slice(1).trim();
213
219
  if (t.length < BUNDLE_EVIDENCE_MIN_CHARS) continue;
214
- if (!byFile.has(cur)) byFile.set(cur, []);
215
- byFile.get(cur).push(t);
220
+ if (!evidence.has(cur)) evidence.set(cur, []);
221
+ evidence.get(cur).push(t);
216
222
  }
217
- return byFile;
223
+ return { evidence, added };
224
+ }
225
+ function diffAddedLinesByFile(diff) { return scanDiff(diff).evidence; }
226
+
227
+ // The order the bundle is filled in (task 1004932): the files the diff adds the
228
+ // most lines to first, so when the total cap bites it is the lightly touched
229
+ // files (a one-line doc pointer, a generated index) that fall to skipped[] for
230
+ // the worker to Read, not the heart of the change. Stable: ties, and every file
231
+ // when there is no diff, keep the caller's order.
232
+ function orderByChange(changedFiles, added) {
233
+ return changedFiles
234
+ .map((file, i) => ({ file, i, n: added.get(file) || 0 }))
235
+ .sort((a, b) => (b.n - a.n) || (a.i - b.i))
236
+ .map((x) => x.file);
218
237
  }
219
238
 
220
239
  // Pure-ish (fs reads only, injectable): returns { files: [{file, content,
@@ -241,8 +260,8 @@ function buildContextBundle({ changedFiles, cwd, diff, fsImpl = fs } = {}) {
241
260
  // exactly the pre-change state). A prefix that contains none of the change is
242
261
  // worse than no bundle at all, so refuse it: skipped[] already tells the worker
243
262
  // to Read the file.
244
- const addedByFile = diffAddedLinesByFile(diff);
245
- for (const rel of (Array.isArray(changedFiles) ? changedFiles : [])) {
263
+ const { evidence: addedByFile, added } = scanDiff(diff);
264
+ for (const rel of orderByChange(Array.isArray(changedFiles) ? changedFiles : [], added)) {
246
265
  if (files.length >= CONTEXT_BUNDLE_MAX_FILES || total >= CONTEXT_BUNDLE_MAX_TOTAL_CHARS) {
247
266
  skipped.push(rel);
248
267
  continue;
@@ -106,24 +106,24 @@
106
106
  </div>
107
107
 
108
108
  <script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
109
- <script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
110
- <script src="/builders/craft-mode.js?v=2026-10-09-living3"></script>
111
- <script src="/builders/shell.js?v=2026-10-25-escape"></script>
109
+ <script src="/builders/shell-trail.js?v=2026-10-08-city-tab" defer></script>
110
+ <script src="/builders/craft-mode.js?v=2026-10-09-living3" defer></script>
111
+ <script src="/builders/shell.js?v=2026-10-25-escape" defer></script>
112
112
  <script src="/builders/palette.js" defer></script>
113
- <script src="/builders/gov-living.js?v=2026-10-09-positions1"></script>
114
- <script src="/builders/gov-feeds.js?v=2026-10-09-living3"></script>
115
- <script src="/builders/city-draw.js?v=2026-10-17-tidy"></script>
113
+ <script src="/builders/gov-living.js?v=2026-10-09-positions1" defer></script>
114
+ <script src="/builders/gov-feeds.js?v=2026-10-09-living3" defer></script>
115
+ <script src="/builders/city-draw.js?v=2026-10-17-tidy" defer></script>
116
116
  <!-- a building's card answers its first ask in place through the deck's own
117
117
  route (task 1004702): the desk rows (street-desk.js over the street's kit)
118
118
  and the deck's card and sender (street-deck.js) -->
119
- <script src="/builders/hall-kit.js?v=2026-10-23-decide12"></script>
120
- <script src="/builders/street-draw.js?v=2026-10-09-living3"></script>
121
- <script src="/builders/street-card.js?v=2026-10-04-record1"></script>
122
- <script src="/builders/street-inside.js?v=2026-10-09-living3"></script>
123
- <script src="/builders/street-desk.js?v=2026-10-04-record1"></script>
124
- <script src="/builders/street-deck.js?v=2026-10-13-glass1"></script>
125
- <script src="/builders/city-decide.js?v=2026-10-23-decide12"></script>
126
- <script src="/builders/city-switchboard.js?v=2026-10-23-board5"></script>
127
- <script src="/builders/city.js?v=2026-10-23-decide12"></script>
119
+ <script src="/builders/hall-kit.js?v=2026-10-23-decide12" defer></script>
120
+ <script src="/builders/street-draw.js?v=2026-10-09-living3" defer></script>
121
+ <script src="/builders/street-card.js?v=2026-10-04-record1" defer></script>
122
+ <script src="/builders/street-inside.js?v=2026-10-09-living3" defer></script>
123
+ <script src="/builders/street-desk.js?v=2026-10-04-record1" defer></script>
124
+ <script src="/builders/street-deck.js?v=2026-10-13-glass1" defer></script>
125
+ <script src="/builders/city-decide.js?v=2026-10-23-decide12" defer></script>
126
+ <script src="/builders/city-switchboard.js?v=2026-10-23-board5" defer></script>
127
+ <script src="/builders/city.js?v=2026-10-23-decide12" defer></script>
128
128
  </body>
129
129
  </html>
@@ -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.21",
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.21",
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.21",
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",
@@ -10515,5 +10515,71 @@
10515
10515
  "id": "1004904",
10516
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
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
+ }
10518
10584
  ]
10519
10585
  }
@@ -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) ---