@bongos/core 1.21.112 → 1.21.114

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 (265) hide show
  1. package/.bongos-core.json +616 -186
  2. package/.claude/skills/ask-for-help/SKILL.md +1 -1
  3. package/.claude/skills/bongos-feedback/SKILL.md +1 -1
  4. package/.claude/skills/builder-sequence/SKILL.md +1 -1
  5. package/.claude/skills/builder-ship/SKILL.md +1 -0
  6. package/GOVERNMENT.md +128 -11
  7. package/clients/bongos-client/README.md +1 -1
  8. package/clients/bongos-client/bongos-client.global.js +28 -0
  9. package/clients/bongos-client/index.cjs +28 -0
  10. package/clients/bongos-client/index.d.ts +45 -2
  11. package/clients/bongos-client/index.mjs +28 -0
  12. package/docs/adr/0372-an-ask-is-stored-per-docket-matter-and-the-server-refuses-slop.md +33 -0
  13. package/docs/adr/0373-an-ai-session-files-a-blocker-with-an-ask.md +31 -0
  14. package/docs/adr/0374-a-building-lead-is-a-held-position-appointed-by-a-people-matter.md +30 -0
  15. package/docs/adr/0375-who-may-build-without-a-vote-comes-from-the-form-of-government.md +38 -0
  16. package/docs/adr/0376-every-ask-is-routed-to-a-real-seat-on-the-server.md +30 -0
  17. package/docs/adr/0377-governance-is-a-held-position-read-on-me.md +24 -0
  18. package/docs/adr/0378-a-leads-build-outs-are-rows-built-at-once-or-by-a-passed-vote.md +33 -0
  19. package/docs/adr/0379-the-treasury-is-pots-per-goal-a-reserve-and-borrowing-over-real-spend.md +32 -0
  20. package/docs/adr/0380-a-strictness-preset-is-a-bundle-of-laws-switched-by-one-laws-vote.md +26 -0
  21. package/docs/adr/README.md +10 -1
  22. package/docs/api/openapi.json +989 -87
  23. package/docs/api-reference.md +18 -4
  24. package/docs/copy-inventory.md +343 -262
  25. package/docs/copy-registry.json +1223 -426
  26. package/docs/file-map.md +2 -1
  27. package/docs/module-api-changelog.md +4 -0
  28. package/docs/packs/engineer.md +2 -1
  29. package/docs/page-inventory.json +91 -6
  30. package/docs/page-readings.json +5017 -4458
  31. package/modules/builder-settings/law-locks.js +202 -0
  32. package/modules/builder-settings/routes/settings.js +66 -6
  33. package/modules/builder-settings/settings.js +7 -0
  34. package/modules/discord/ship-broadcast.js +22 -13
  35. package/modules/government/asks-db.js +54 -0
  36. package/modules/government/asks.js +269 -0
  37. package/modules/government/board-idea-reads.js +68 -0
  38. package/modules/government/board.js +21 -57
  39. package/modules/government/build-rights.js +148 -0
  40. package/modules/government/buildouts.js +160 -0
  41. package/modules/government/counts.js +81 -0
  42. package/modules/government/db.js +242 -0
  43. package/modules/government/decision-board.js +234 -8
  44. package/modules/government/decision-rules.js +2 -0
  45. package/modules/government/docket.js +114 -6
  46. package/modules/government/law-board.js +131 -0
  47. package/modules/government/laws.js +241 -0
  48. package/modules/government/leads.js +170 -0
  49. package/modules/government/migrations/government_027_laws.sql +143 -0
  50. package/modules/government/migrations/government_028_asks.sql +57 -0
  51. package/modules/government/migrations/government_029_leads.sql +135 -0
  52. package/modules/government/migrations/government_030_buildouts.sql +111 -0
  53. package/modules/government/migrations/government_031_treasury.sql +107 -0
  54. package/modules/government/module.json +2 -1
  55. package/modules/government/pollers/treasury-borrow.js +62 -0
  56. package/modules/government/positions.js +107 -0
  57. package/modules/government/presets.js +149 -0
  58. package/modules/government/routes/government.js +425 -2
  59. package/modules/government/routing.js +154 -0
  60. package/modules/government/treasury-db.js +166 -0
  61. package/modules/government/treasury.js +241 -0
  62. package/modules/hall-ui/public/atlas.html +2 -2
  63. package/modules/hall-ui/public/blockers.html +2 -2
  64. package/modules/hall-ui/public/board-room.html +2 -2
  65. package/modules/hall-ui/public/board-room.js +2 -0
  66. package/modules/hall-ui/public/city-draw.js +604 -11
  67. package/modules/hall-ui/public/city.css +157 -1
  68. package/modules/hall-ui/public/city.html +37 -7
  69. package/modules/hall-ui/public/city.js +441 -21
  70. package/modules/hall-ui/public/city.states.json +110 -5
  71. package/modules/hall-ui/public/collab.html +2 -2
  72. package/modules/hall-ui/public/commands.html +2 -2
  73. package/modules/hall-ui/public/copy-desk.html +2 -2
  74. package/modules/hall-ui/public/craft-mode.js +112 -29
  75. package/modules/hall-ui/public/deploy.html +2 -2
  76. package/modules/hall-ui/public/diagram-viewer.css +12 -0
  77. package/modules/hall-ui/public/diagram-viewer.js +4 -0
  78. package/modules/hall-ui/public/diagrams.html +2 -2
  79. package/modules/hall-ui/public/drachmae.html +2 -2
  80. package/modules/hall-ui/public/fleet.html +2 -2
  81. package/modules/hall-ui/public/gate.html +2 -2
  82. package/modules/hall-ui/public/goal-map.html +2 -2
  83. package/modules/hall-ui/public/goals.html +2 -2
  84. package/modules/hall-ui/public/gov-feeds.js +141 -0
  85. package/modules/hall-ui/public/gov-living.js +1002 -0
  86. package/modules/hall-ui/public/gov-positions.js +97 -0
  87. package/modules/hall-ui/public/government.html +2 -2
  88. package/modules/hall-ui/public/hall-render.js +3 -0
  89. package/modules/hall-ui/public/idea.html +2 -2
  90. package/modules/hall-ui/public/ideas.html +2 -2
  91. package/modules/hall-ui/public/index.html +3 -3
  92. package/modules/hall-ui/public/modules.html +2 -2
  93. package/modules/hall-ui/public/my-agents.css +334 -0
  94. package/modules/hall-ui/public/my-agents.html +72 -0
  95. package/modules/hall-ui/public/my-agents.js +603 -0
  96. package/modules/hall-ui/public/my-agents.states.json +37 -0
  97. package/modules/hall-ui/public/primer.html +2 -2
  98. package/modules/hall-ui/public/profile-connections.js +40 -28
  99. package/modules/hall-ui/public/profile.html +2 -2
  100. package/modules/hall-ui/public/project-settings.html +2 -2
  101. package/modules/hall-ui/public/ranks.html +2 -2
  102. package/modules/hall-ui/public/roadmap.html +2 -2
  103. package/modules/hall-ui/public/room.css +8 -0
  104. package/modules/hall-ui/public/roster.html +2 -2
  105. package/modules/hall-ui/public/sessions.html +2 -2
  106. package/modules/hall-ui/public/settings.html +2 -2
  107. package/modules/hall-ui/public/shell.js +116 -16
  108. package/modules/hall-ui/public/street-card.js +18 -1
  109. package/modules/hall-ui/public/street-deck.css +299 -0
  110. package/modules/hall-ui/public/street-deck.js +1145 -0
  111. package/modules/hall-ui/public/street-depth.js +712 -0
  112. package/modules/hall-ui/public/street-desk.js +5 -2
  113. package/modules/hall-ui/public/street-draw.js +109 -16
  114. package/modules/hall-ui/public/street-inside.js +137 -22
  115. package/modules/hall-ui/public/street-office.js +705 -0
  116. package/modules/hall-ui/public/street.css +161 -11
  117. package/modules/hall-ui/public/street.html +41 -11
  118. package/modules/hall-ui/public/street.js +78 -66
  119. package/modules/hall-ui/public/street.states.json +129 -21
  120. package/modules/hall-ui/public/studio.html +2 -2
  121. package/modules/hall-ui/public/style.css +5 -1
  122. package/modules/hall-ui/public/task.html +2 -2
  123. package/modules/hall-ui/public/thinking.html +2 -2
  124. package/modules/hall-ui/public/tweak-editor.html +2 -2
  125. package/modules/hall-ui/public/watch.html +2 -2
  126. package/modules/hall-ui/public/watch.js +14 -1
  127. package/modules/hall-ui/public/work.html +2 -2
  128. package/modules/hall-ui/records/living-city.md +57 -0
  129. package/modules/hall-ui/records/my-agents.md +21 -0
  130. package/modules/hall-ui/records/oversight-family.md +1 -0
  131. package/modules/hall-ui/records/profile-connections.md +10 -0
  132. package/modules/hall-ui/records/reading-rooms.md +2 -0
  133. package/modules/ideas/routes/blockers.js +92 -3
  134. package/modules/lifecycle/module.json +2 -1
  135. package/modules/lifecycle/moving.js +17 -1
  136. package/modules/lifecycle/routes/moving.js +6 -0
  137. package/modules/lifecycle/routes/project-settings.js +23 -0
  138. package/modules/onboarding/approval-broadcast.js +8 -0
  139. package/modules/onboarding/routes/access-requests.js +16 -6
  140. package/modules/platform-identity/connections.js +13 -2
  141. package/modules/platform-identity/routes/connections.js +7 -1
  142. package/modules/public-landing/public/account-private.states.json +2 -2
  143. package/modules/public-landing/public/account.html +65 -5
  144. package/modules/public-landing/public/account.states.json +3 -3
  145. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
  146. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
  147. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +13 -0
  148. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
  149. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
  150. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +13 -0
  151. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
  152. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
  153. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +13 -0
  154. package/modules/public-landing/public/assets/world.css +4 -4
  155. package/modules/public-landing/public/connections-empty.states.json +14 -0
  156. package/modules/public-landing/public/connections-inactive.states.json +14 -0
  157. package/modules/public-landing/public/connections.html +450 -0
  158. package/modules/public-landing/public/connections.probes.json +414 -0
  159. package/modules/public-landing/public/connections.states.json +17 -0
  160. package/modules/public-landing/public/contact.html +2 -2
  161. package/modules/public-landing/public/guild.html +2 -2
  162. package/modules/public-landing/public/index.html +2 -2
  163. package/modules/public-landing/public/invitations.html +2 -2
  164. package/modules/public-landing/public/privacy.html +2 -2
  165. package/modules/public-landing/public/projects.html +2 -2
  166. package/modules/public-landing/public/projects.probes.json +6 -2
  167. package/modules/public-landing/public/terms.html +2 -2
  168. package/modules/ui-design/kit/fixtures/connections-empty.json +12 -0
  169. package/modules/ui-design/kit/fixtures/connections-inactive.json +7 -0
  170. package/modules/ui-design/kit/fixtures/connections.json +22 -0
  171. package/modules/ui-design/kit/serve.js +95 -0
  172. package/package-lock.json +2 -2
  173. package/package.json +1 -1
  174. package/release-notes.json +128 -0
  175. package/scripts/discord/README.md +8 -5
  176. package/scripts/discord/post.js +12 -1
  177. package/scripts/gds/artifact-audit.js +20 -3
  178. package/scripts/gds/ask.js +164 -0
  179. package/scripts/gds/autobongos-run.js +20 -5
  180. package/scripts/gds/autobongos-tweak-lane.js +262 -0
  181. package/scripts/gds/autobongos-verify.js +21 -2
  182. package/scripts/gds/claim.js +2 -0
  183. package/scripts/gds/government-walk.js +638 -4
  184. package/scripts/gds/pulse.js +9 -6
  185. package/scripts/gds/ship-escalation.js +22 -3
  186. package/scripts/gds/ship-finish.js +5 -4
  187. package/scripts/gds/ship-merge-gate.js +18 -6
  188. package/scripts/gds/tweak-halt.js +1 -0
  189. package/scripts/hall-preview/README.md +41 -1
  190. package/scripts/hall-preview/pref-writes.js +140 -0
  191. package/scripts/hall-preview/refresh-fixtures.js +101 -7
  192. package/scripts/hall-preview/scrub-identity.js +366 -7
  193. package/scripts/hall-preview/server.js +27 -1
  194. package/scripts/render-diagrams.sh +4 -2
  195. package/src/bongos/auth-idp.js +6 -1
  196. package/src/bongos/project-settings.js +41 -0
  197. package/src/bongos/route-rank-check.js +27 -0
  198. package/src/bongos/routes/me.js +68 -7
  199. package/src/bongos/serve-internal.js +5 -1
  200. package/src/module-api.js +7 -1
  201. package/src/platform-server.js +10 -0
  202. package/tests/application_lifecycle.mjs +47 -5
  203. package/tests/autobongos_tweak_lane.mjs +208 -0
  204. package/tests/conductor_main_e2e.mjs +21 -0
  205. package/tests/connections_api.mjs +18 -0
  206. package/tests/craft_mode_nav.mjs +5 -3
  207. package/tests/craft_mode_tallies.mjs +52 -16
  208. package/tests/discord_approvals.mjs +11 -0
  209. package/tests/discord_channels.mjs +20 -0
  210. package/tests/fixtures/gov-seated.mjs +64 -0
  211. package/tests/fixtures/government-ask-cards.json +1284 -0
  212. package/tests/government_act_alone_buildings.mjs +1 -0
  213. package/tests/government_agent_asks.mjs +322 -0
  214. package/tests/government_asks.mjs +408 -0
  215. package/tests/government_build_rights.mjs +253 -0
  216. package/tests/government_buildouts.mjs +382 -0
  217. package/tests/government_counts.mjs +143 -0
  218. package/tests/government_decision_rules.mjs +5 -1
  219. package/tests/government_laws.mjs +488 -0
  220. package/tests/government_laws_session.mjs +321 -0
  221. package/tests/government_leads.mjs +375 -0
  222. package/tests/government_positions.mjs +127 -0
  223. package/tests/government_presets.mjs +215 -0
  224. package/tests/government_routes.mjs +8 -1
  225. package/tests/government_routing.mjs +118 -0
  226. package/tests/government_seed.mjs +5 -0
  227. package/tests/government_treasury.mjs +394 -0
  228. package/tests/governor_docket.mjs +26 -2
  229. package/tests/hall_buildouts.mjs +208 -0
  230. package/tests/hall_city.mjs +14 -6
  231. package/tests/hall_city_living.mjs +617 -0
  232. package/tests/hall_gov_counts.mjs +336 -0
  233. package/tests/hall_gov_living.mjs +636 -0
  234. package/tests/hall_gov_real_site.mjs +655 -0
  235. package/tests/hall_matter_card.mjs +6 -1
  236. package/tests/hall_my_agents.mjs +481 -0
  237. package/tests/hall_nav.mjs +107 -4
  238. package/tests/hall_page_gate_map.mjs +4 -0
  239. package/tests/hall_preview_governor_fixtures.mjs +30 -3
  240. package/tests/hall_preview_scrub.mjs +59 -1
  241. package/tests/hall_preview_security.mjs +169 -0
  242. package/tests/hall_reading_world.mjs +44 -0
  243. package/tests/hall_roster_world.mjs +6 -2
  244. package/tests/hall_street.mjs +18 -2
  245. package/tests/hall_street_amend.mjs +1 -1
  246. package/tests/hall_street_deck.mjs +609 -0
  247. package/tests/hall_street_depth.mjs +559 -0
  248. package/tests/hall_street_desk.mjs +1 -1
  249. package/tests/hall_street_inside.mjs +29 -5
  250. package/tests/hall_tweak_editor.mjs +4 -1
  251. package/tests/hub_account_page.mjs +94 -3
  252. package/tests/hub_connections_page.mjs +507 -0
  253. package/tests/hub_invite_badge.mjs +1 -1
  254. package/tests/idp_device_exchange.mjs +18 -0
  255. package/tests/landing_legal_pages.mjs +8 -5
  256. package/tests/landing_page.mjs +23 -4
  257. package/tests/nav_permission_atoms.mjs +4 -1
  258. package/tests/profile_connections_ui.mjs +88 -25
  259. package/tests/projects_hub.mjs +15 -0
  260. package/tests/render_check.mjs +2 -2
  261. package/tests/ship_broadcast_routing.mjs +57 -16
  262. package/tests/ship_broadcast_server.mjs +40 -7
  263. package/tests/ship_merge_gate.mjs +19 -0
  264. package/tests/ui_design_kit.mjs +2 -2
  265. package/tests/watch_applications_queue.mjs +23 -0
@@ -122,7 +122,9 @@ const discordIsLinked = (builderId) =>
122
122
  // `idea.triage` (task 1004231): the sky's panel draws "nod — make it claimable"
123
123
  // on a thought awaiting a nod only for a builder who may triage ideas; the nod
124
124
  // itself runs the existing POST /tasks/:id/promote, which stays the wall.
125
- const NAV_PERMISSION_ATOMS = Object.freeze(['board.vote.cast', 'core.pin.move', 'idea.triage']);
125
+ // `board.item.open` (task 1004714): My agents offers 'Make it a law' — a laws vote,
126
+ // POST /government/board/laws — only to a builder who may open a board item.
127
+ const NAV_PERMISSION_ATOMS = Object.freeze(['board.vote.cast', 'core.pin.move', 'idea.triage', 'board.item.open']);
126
128
 
127
129
  // navPermissionsFor(builderId) → { '<atom>': boolean } over NAV_PERMISSION_ATOMS,
128
130
  // or null when authority could not be established (government off, port missing,
@@ -187,7 +189,7 @@ module.exports = function buildMeRouter() {
187
189
  recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, cliAuthed,
188
190
  behaviorPrefs, geminiKeyRow, needsRebaseTasks, foundingBuilder, helpRequestCount,
189
191
  taskRecommendationCount, boardVotesPending, socialMode, mingleIntroductionCount,
190
- navPermissions, foundingMode,
192
+ navPermissions, foundingMode, sessionLaws, govPositions,
191
193
  ] = await Promise.all([
192
194
  db.getBuilderById(req.builder.id),
193
195
  lc().getActiveClaimsForBuilder(req.builder.id),
@@ -318,7 +320,44 @@ module.exports = function buildMeRouter() {
318
320
  return await onboarding.foundingModeFor();
319
321
  } catch (_) { return null; }
320
322
  })(),
323
+ // task 1004707: the project's LAWS — what every AI session on the project
324
+ // reads, via the `government` port (core imports no module, ADR 0093).
325
+ // Read per request, uncached, so a law passed a moment ago is in the next
326
+ // session. null when the module is off or the read failed: the session then
327
+ // gets no laws block, and the settings slices resolve without locks.
328
+ (async () => {
329
+ try {
330
+ const government = seams.resolveOptional('government');
331
+ if (!government || typeof government.lawsForSession !== 'function') return null;
332
+ return await government.lawsForSession();
333
+ } catch (err) {
334
+ log.error({ err: err && err.message }, 'GET /me laws');
335
+ return null;
336
+ }
337
+ })(),
338
+ // task 1004713: the government POSITIONS this builder holds (owner, Lead,
339
+ // a building's sitting, the board), via the `government` port. Read per
340
+ // request, uncached. null when the module is off or a read failed.
341
+ (async () => {
342
+ try {
343
+ const government = seams.resolveOptional('government');
344
+ if (!government || typeof government.positionsFor !== 'function') return null;
345
+ return await government.positionsFor(req.builder.id);
346
+ } catch (err) {
347
+ log.error({ err: err && err.message }, 'GET /me positions');
348
+ return null;
349
+ }
350
+ })(),
321
351
  ]);
352
+ // task 1004707: a law's LOCKS bite on the three settings slices below
353
+ // (interaction, wandering, model allocation): their effective values and
354
+ // injected contracts are resolved with every lock applied, server-side —
355
+ // the builder-settings port's describeWithLaws is the SSOT. A builder's own
356
+ // stored choice is still reported as theirs.
357
+ const instanceModels = (() => { try { return branding().models || {}; } catch { return {}; } })();
358
+ const lawed = settings && typeof settings.describeWithLaws === 'function'
359
+ ? settings.describeWithLaws(behaviorPrefs, (sessionLaws && sessionLaws.laws) || [], { rank: builder && builder.rank, instanceModels })
360
+ : null;
322
361
  // V3.R84 (#302), criterion C3: the builder's month-to-date spend vs their
323
362
  // monthly budget. Surfaced here so the CLI (session-start ping) and the hall
324
363
  // profile banner read it without a follow-up call; the ship + art-pipeline
@@ -401,6 +440,14 @@ module.exports = function buildMeRouter() {
401
440
  // floor. null when authority could not be established, which the shell
402
441
  // reads as nothing-visible. Cosmetic only — ADR 0016, the server enforces.
403
442
  permissions: navPermissions,
443
+ // task 1004713 (owner ruling 2026-10-09: governance is a HELD POSITION, not a
444
+ // picked craft): { held: [{ kind, seat, building, title }], title,
445
+ // has_position, from } — the seats this builder holds, resolved server-side
446
+ // (modules/government/positions.js). The hall shows the governing surfaces
447
+ // and names the Governing mode by `title` only for a holder. null when it
448
+ // could not be read, which the hall reads as unknown (today's nav), never
449
+ // "holds none". Cosmetic only — every governing page keeps its own gate.
450
+ positions: govPositions,
404
451
  unlocked,
405
452
  locked_with_progress: lockedWithProgress,
406
453
  streak: {
@@ -469,13 +516,27 @@ module.exports = function buildMeRouter() {
469
516
  // level (clamped by rank) + its behavior contract. The Conductor + the
470
517
  // /settings UI read this; the builder-settings port's describeWandering is
471
518
  // the SSOT (BV1.R80). null when the prefs module is off.
472
- wandering: settings ? settings.describeWandering(behaviorPrefs, builder.rank) : null,
519
+ wandering: lawed ? lawed.wandering : (settings ? settings.describeWandering(behaviorPrefs, builder.rank) : null),
473
520
  // task 2010: the per-builder interaction profile (tone / technical level /
474
521
  // toolchain familiarity), resolved to an effective profile + an injectable
475
522
  // contract. The old §2 "The Prompter" owner-defaults, now per-builder. The
476
523
  // session-start injection + /settings UI read this; describeInteraction is
477
524
  // the SSOT (builder-settings port). null when the prefs module is off.
478
- interaction: settings && settings.describeInteraction ? settings.describeInteraction(behaviorPrefs) : null,
525
+ interaction: lawed ? lawed.interaction : (settings && settings.describeInteraction ? settings.describeInteraction(behaviorPrefs) : null),
526
+ // task 1004707: the project's LAWS, read into every AI session. The
527
+ // Conductor hook relays `contract` verbatim beside the interaction profile
528
+ // (inject:false, and no block, when no law is in force). `laws` is the book
529
+ // as the session reads it, numbered; `locks` is what each law's lock did to
530
+ // THIS builder's settings ({ setting, law_id, enforced, from, to } — a lock on
531
+ // a setting no table holds yet is enforced:false, its law's words still
532
+ // apply). The server writes all of it; nothing local can change it. null when
533
+ // the laws could not be read.
534
+ laws: sessionLaws ? {
535
+ inject: !!sessionLaws.inject,
536
+ contract: sessionLaws.contract || '',
537
+ laws: Array.isArray(sessionLaws.laws) ? sessionLaws.laws : [],
538
+ locks: lawed ? lawed.locks : [],
539
+ } : null,
479
540
  // task 1003855: the builder's ACTIVE specialities, one per craft at most
480
541
  // (the speciality_adoptions partial unique index enforces that). Each entry
481
542
  // carries its own injectable contract; the Conductor picks the one matching
@@ -491,9 +552,9 @@ module.exports = function buildMeRouter() {
491
552
  // task 2011: the resolved subagent model allocation (main + subagent tiers),
492
553
  // builder override → instance default (branding.models) → code default. The
493
554
  // agent consults `effective` when spawning subagents; §2 links here.
494
- model_allocation: settings && settings.describeModelAllocation
495
- ? settings.describeModelAllocation(behaviorPrefs, (() => { try { return branding().models || {}; } catch { return {}; } })())
496
- : null,
555
+ model_allocation: lawed ? lawed.model_allocation : (settings && settings.describeModelAllocation
556
+ ? settings.describeModelAllocation(behaviorPrefs, instanceModels)
557
+ : null),
497
558
  // task 1279: the per-builder "render widgets" knob (shares behavior_prefs).
498
559
  // { widgets_enabled } tells the start/claim/ship CLIs whether to emit the
499
560
  // rich HTML card or the cheap text fallback. No rank cap (cost/taste, not
@@ -1245,8 +1245,12 @@ function mountInternalSurfaces(app) {
1245
1245
  // task 1004473: `commands`, the Commands page — every skill and every bongos CLI
1246
1246
  // verb. Both reads behind it are requireBuilder, so it sits on this tier;
1247
1247
  // reading the list grants nothing (ADR 0310 §1).
1248
+ // task 1004685 (the living-city prototype): `my-agents` — how the viewer's own
1249
+ // Claude works for them. Every read behind it is the viewer's own and
1250
+ // requireBuilder (GET /me and its /me/* prefs, /specialities), so sign-in is the
1251
+ // whole gate; the governing reads it adds are gated by their own routes.
1248
1252
  const SIGNEDIN_BUILDERS_PAGE_RE =
1249
- /^\/builders\/(?:work|settings|atlas|modules|goals|goal-map|roadmap|copy-desk|studio|tweak-editor|ideas|thinking|collab|task|idea|primer(?:\.md)?|diagrams|commands)(?:\.html)?\/?$/;
1253
+ /^\/builders\/(?:work|settings|atlas|modules|goals|goal-map|roadmap|copy-desk|studio|tweak-editor|ideas|thinking|collab|task|idea|primer(?:\.md)?|diagrams|my-agents|commands)(?:\.html)?\/?$/;
1250
1254
  // The diagram image assets under /builders/diagrams/ ride the same tier as the
1251
1255
  // page that embeds them.
1252
1256
  const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
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.112'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.114'; // 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');
@@ -473,6 +473,12 @@ module.exports = {
473
473
  get SHIP_MODES() { return require('./bongos/project-settings').SHIP_MODES; },
474
474
  get parseShipMode() { return require('./bongos/project-settings').parseShipMode; },
475
475
  get resolveShipMode() { return require('./bongos/project-settings').resolveShipMode; },
476
+ // The spend cap (task 1004712, ADR 0379): off by default (no limit, the owner's
477
+ // ruling). The treasury reads it; PATCH /project-settings/spend-cap writes it.
478
+ get parseSpendCap() { return require('./bongos/project-settings').parseSpendCap; },
479
+ get resolveSpendCap() { return require('./bongos/project-settings').resolveSpendCap; },
480
+ get setSpendCap() { return require('./bongos/project-settings').setSpendCap; },
481
+ get describeSpendCap() { return require('./bongos/project-settings').describeSpendCap; },
476
482
  get describeShipMode() { return require('./bongos/project-settings').describeShipMode; },
477
483
  // Tweak auto-pickup (task 1004677, ADR 0371 D3/D5): the switch and the monthly cap.
478
484
  get AUTOPICKUP_MODES() { return require('./bongos/project-settings').AUTOPICKUP_MODES; },
@@ -316,6 +316,16 @@ function buildPlatformApp() {
316
316
  if (!html) return next();
317
317
  res.type('html').send(html);
318
318
  });
319
+ // THE CONNECTIONS PAGE (task 1004278, ADR 0337 D5.4): the hub is the primary
320
+ // home for connections — your connections across every project, the requests
321
+ // in both directions, and "Builders you may know". Hub-only for the /account
322
+ // reason: every read and write it makes is a platform-identity route.
323
+ app.get('/connections', (req, res, next) => {
324
+ if (!isApexHost(req) || !isModuleEnabled('platform-identity')) return next();
325
+ const html = landingPage('connections.html');
326
+ if (!html) return next();
327
+ res.type('html').send(html);
328
+ });
319
329
  // THE GUILD PAGE (task 1002301, R25; ADR 0336 D4/D6): /g/<slug>, the namespace
320
330
  // D6 reserved. Hub-only for the /account reason — every read it makes is a
321
331
  // platform-identity route. ONE shell for every slug: the page reads the public
@@ -310,12 +310,12 @@ const pool = {
310
310
 
311
311
  // A vouched re-application taking over an unvouched pending row (ADR 0333 D4).
312
312
  // The SET list is matched exactly, like the resolve's below.
313
- if ((m = /^UPDATE access_requests SET display_name = \$2, note = \$3, applicant_github_id = \$4, applicant_handle = \$5, vouched_at = \$6 WHERE (.+?)$/.exec(sql))) {
313
+ if ((m = /^UPDATE access_requests SET display_name = \$2, note = \$3, applicant_github_id = \$4, applicant_handle = \$5, vouched_at = \$6 WHERE (.+?) RETURNING id, github_login, status, created_at$/.exec(sql))) {
314
314
  const hit = rows.filter(predicateFor(m[1], params));
315
315
  for (const r of hit) {
316
316
  [r.display_name, r.note, r.applicant_github_id, r.applicant_handle, r.vouched_at] = params.slice(1, 6);
317
317
  }
318
- return { rows: [], rowCount: hit.length };
318
+ return { rows: hit.map((r) => ({ id: r.id, github_login: r.github_login, status: r.status, created_at: r.created_at })), rowCount: hit.length };
319
319
  }
320
320
 
321
321
  // The resolve — one UPDATE, one WHERE, and that WHERE is the whole rule.
@@ -354,11 +354,13 @@ const poolPath = require.resolve('../src/bongos/pool.js');
354
354
  require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool } };
355
355
 
356
356
  // Discord broadcast is fire-and-forget on the public write; stubbed so the suite
357
- // is hermetic (it is best-effort in production and never affects the queue).
357
+ // is hermetic (it is best-effort in production and never affects the queue), and
358
+ // recorded so a test can see which #member-approvals lines would have gone out.
359
+ let broadcasts = [];
358
360
  const broadcastPath = require.resolve('../modules/onboarding/approval-broadcast.js');
359
361
  require.cache[broadcastPath] = {
360
362
  id: broadcastPath, filename: broadcastPath, loaded: true,
361
- exports: { postMemberApprovalRequest: async () => ({ posted: false, skipped: 'test' }) },
363
+ exports: { postMemberApprovalRequest: async (request) => { broadcasts.push(request); return { posted: false, skipped: 'test' }; } },
362
364
  };
363
365
 
364
366
  // The hub's side of the vouch, for real: a generated Ed25519 keypair, the real
@@ -452,7 +454,7 @@ function call(method, pathname, body, extraHeaders = {}) {
452
454
  req.end(payload);
453
455
  });
454
456
  }
455
- const reset = (rank = 'archon') => { seed(); actingRank = rank; actingId = 42; notices = []; hubViews = {}; hubAsked = []; };
457
+ const reset = (rank = 'archon') => { seed(); actingRank = rank; actingId = 42; notices = []; hubViews = {}; hubAsked = []; broadcasts = []; };
456
458
 
457
459
  // ===========================================================================
458
460
  // The interpreter is the instrument, so it is calibrated first
@@ -618,6 +620,19 @@ test('a vouched re-application takes over an UNVOUCHED pending row for its own l
618
620
  assert.equal(row(1).status, 'pending', 'and it decides nothing');
619
621
  });
620
622
 
623
+ // The first #member-approvals line still quotes the words the row held before the
624
+ // takeover, and a webhook cannot edit it; so the takeover posts the row's words now.
625
+ test('a vouched takeover re-posts the #member-approvals line with the words the row holds now', async () => {
626
+ reset();
627
+ await applyAs('ada', { [VOUCH_HEADER]: vouchFor('ada', { githubId: 8001, handle: 'ada-h' }) });
628
+ assert.equal(broadcasts.length, 1, 'one line, for the takeover — the duplicate INSERT posts nothing');
629
+ const [line] = broadcasts;
630
+ assert.equal(line.id, 1, 'the same request, so a 👍 on either line resolves the one row');
631
+ assert.equal(line.refiled, true);
632
+ assert.equal(line.display_name, 'New');
633
+ assert.match(line.note, /via platform My Projects/);
634
+ });
635
+
621
636
  test('a re-application never re-stamps an ALREADY vouched row, and an unvouched one changes nothing', async () => {
622
637
  reset();
623
638
  const bo = { ...row(2) };
@@ -626,6 +641,33 @@ test('a re-application never re-stamps an ALREADY vouched row, and an unvouched
626
641
  const ada = { ...row(1) };
627
642
  await applyAs('ada');
628
643
  assert.deepEqual(row(1), ada, 'an unvouched re-apply is the idempotent success it always was');
644
+ assert.deepEqual(broadcasts, [], 'and neither re-posts a line: nothing on the row changed');
645
+ });
646
+
647
+ // END TO END: a row written by the REAL vouch writer is the row the queue resolves
648
+ // from the hub. The profile tests below seed their vouched rows; this one proves the
649
+ // link between the two halves — the id the writer stores is the id the read asks for.
650
+ test('list-pending: the real vouch writer’s row resolves its profile and guild from the hub', async () => {
651
+ reset();
652
+ const profile = { view: 'public', account: { handle: 'newcomer-h', bio: 'hi' }, cross_project: null };
653
+ hubViews['7001'] = { profile, guild: { slug: 'lantern-works', name: 'Lantern Works' } };
654
+ await applyAs('Newcomer', { [VOUCH_HEADER]: vouchFor('newcomer') });
655
+ const r = await call('GET', '/api/gds/access-requests');
656
+ const got = r.json.requests.find((x) => x.github_login === 'Newcomer');
657
+ assert.ok(hubAsked.includes('7001'), 'the hub was asked about the id the vouch carried');
658
+ assert.deepEqual(got.profile, profile);
659
+ assert.deepEqual(got.guild, { slug: 'lantern-works', name: 'Lantern Works' });
660
+ });
661
+
662
+ test('list-pending: a vouched TAKEOVER resolves the vouched applicant, not whoever filed first', async () => {
663
+ reset();
664
+ hubViews['8001'] = { profile: { view: 'sliver', sliver: { disciplines: ['engineer'] } }, guild: null };
665
+ await applyAs('ada', { [VOUCH_HEADER]: vouchFor('ada', { githubId: 8001, handle: 'ada-h' }) });
666
+ const r = await call('GET', '/api/gds/access-requests');
667
+ const ada = r.json.requests.find((x) => x.github_login === 'ada');
668
+ assert.equal(ada.vouched, true);
669
+ assert.deepEqual(ada.profile, hubViews['8001'].profile);
670
+ assert.ok(hubAsked.includes('8001'));
629
671
  });
630
672
 
631
673
  test('list-pending: the queue carries the handle the vouch brought, beside the vouch mark', async () => {
@@ -0,0 +1,208 @@
1
+ // tests/autobongos_tweak_lane.mjs — the Autobongos tweak lane (task 1004679 /
2
+ // BV2.TW19c, ADR 0371 D1, D2, D4, D6, D7).
3
+ //
4
+ // The REAL picker (pickRound) and the REAL fence (modules/autonomy/fence.js +
5
+ // the protected-path matcher) over a fake server, one fixture per rule: off, at
6
+ // the cap, a protected page file, a round applied twice, a failed apply, a runner
7
+ // below Metic. Then the REAL iteration(): a round is worked before ordinary work,
8
+ // with a marked claim and the unattended prompt; with nothing for the lane, an
9
+ // ordinary task is still picked; and a goal-only fence lets the lane run while the
10
+ // kill switch and the grader stop do not.
11
+ //
12
+ // Run: node --test tests/autobongos_tweak_lane.mjs
13
+
14
+ import assert from 'node:assert/strict';
15
+ import { test } from 'node:test';
16
+ import { createRequire } from 'node:module';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const lane = require('../scripts/gds/autobongos-tweak-lane.js');
20
+ const runner = require('../scripts/gds/autobongos-run.js');
21
+ const { matchProtected } = require('../src/bongos/permission-path-check');
22
+
23
+ const INV = { pages: [
24
+ { id: 'landing:index', files: ['modules/public-landing/public/index.html'], states_files: [] },
25
+ { id: 'landing:other', files: ['modules/public-landing/public/other.html'], states_files: [] },
26
+ { id: 'builders:core', files: ['CLAUDE.md'], states_files: [] },
27
+ ] };
28
+ const ON = { mode: 'on', cap: 20, used_this_month: 3, remaining_this_month: 17 };
29
+ const round = (id, over = {}) => ({ task: { id, title: `Tweak ${id}`, status: 'ready', source: 'page-tweak', goal_id: 1000095, description: 'Apply the round.', updated_at: 't1', ...over }, claims: [], grade: null });
30
+
31
+ // A fake server: the setting, the page list, and each round's task + claims.
32
+ function fakeApi({ settings = ON, rank = 'metic', pages, rounds = {} } = {}) {
33
+ return {
34
+ projectSettings: { getProjectSettingsTweakAutopickup: async () => (settings ? { ok: true, data: settings } : { ok: false }) },
35
+ me: { getMe: async () => ({ ok: true, data: { builder: { rank }, grader_bypass: false } }) },
36
+ copyDesk: { getCopyDeskPages: async () => ({ ok: true, data: { pages } }) },
37
+ tasks: { getTasksId: async ({ id }) => (rounds[id] ? { ok: true, data: { task: rounds[id].task, claims: rounds[id].claims, grade: rounds[id].grade } } : { ok: false }) },
38
+ };
39
+ }
40
+ const submitted = (id, taskId) => ({ id, open_round: { task_id: String(taskId), state: 'submitted' } });
41
+ const pick = (api, state = {}) => lane.pickRound({ api, state, matchProtected, readInventory: () => INV });
42
+
43
+ test('the oldest submitted round is picked, by task id, and only submitted ones', async () => {
44
+ const api = fakeApi({
45
+ pages: [submitted('landing:other', 902), submitted('landing:index', 901), { id: 'x:y', open_round: { task_id: '900', state: 'drafting' } }],
46
+ rounds: { 901: round(901), 902: round(902) },
47
+ });
48
+ const got = await pick(api);
49
+ assert.equal(got.round.task.id, 901);
50
+ assert.equal(got.round.pageId, 'landing:index');
51
+ assert.deepEqual(got.round.files, ['modules/public-landing/public/index.html'], 'the page\'s files are the touches');
52
+ });
53
+
54
+ test('OFF: the lane is silent and takes nothing', async () => {
55
+ const got = await pick(fakeApi({ settings: { ...ON, mode: 'off' }, pages: [submitted('landing:index', 901)], rounds: { 901: round(901) } }));
56
+ assert.equal(got.code, 'lane_off');
57
+ assert.equal(got.quiet, true);
58
+ });
59
+
60
+ test('AT THE CAP: refused, and it says the rounds wait for a person', async () => {
61
+ const got = await pick(fakeApi({ settings: { ...ON, used_this_month: 20, remaining_this_month: 0 }, pages: [submitted('landing:index', 901)], rounds: { 901: round(901) } }));
62
+ assert.equal(got.code, 'lane_at_cap');
63
+ assert.match(got.reason, /wait for a person/);
64
+ });
65
+
66
+ test('A PROTECTED PAGE FILE: the round is left for a person and the next one is taken', async () => {
67
+ const got = await pick(fakeApi({
68
+ pages: [submitted('builders:core', 900), submitted('landing:index', 901)],
69
+ rounds: { 900: round(900), 901: round(901) },
70
+ }));
71
+ assert.equal(got.round.task.id, 901);
72
+ const d = lane.decideRound({ task: round(900).task, claims: [], files: ['CLAUDE.md'], matchProtected });
73
+ assert.equal(d.code, 'protected_path', 'the fence\'s own refusal, over the page\'s files');
74
+ });
75
+
76
+ test('HOST ACCESS: the fence\'s host check runs on the round too', () => {
77
+ const t = { ...round(1).task, automation_tag: 'live-only' };
78
+ assert.equal(lane.decideRound({ task: t, claims: [], files: ['modules/public-landing/public/index.html'], matchProtected }).code, 'needs_host_access');
79
+ });
80
+
81
+ test('SENT BACK TWICE: after two applies by this runner the round waits for a person', async () => {
82
+ const runnerClaim = (n) => ({ worktree_name: `autobongos-901-${n}` });
83
+ const twice = round(901); twice.claims = [runnerClaim('aaaaaa'), runnerClaim('bbbbbb')];
84
+ const got = await pick(fakeApi({ pages: [submitted('landing:index', 901)], rounds: { 901: twice } }));
85
+ assert.equal(got.none, true);
86
+ assert.deepEqual(got.skipped.map((s) => s.code), ['round_waits_for_person']);
87
+ // A person's claims, or one runner apply, do not count against it.
88
+ const once = round(901); once.claims = [runnerClaim('aaaaaa'), { worktree_name: null }, { worktree_name: 'fix-copy-12ab' }];
89
+ assert.equal((await pick(fakeApi({ pages: [submitted('landing:index', 901)], rounds: { 901: once } }))).round.task.id, 901);
90
+ });
91
+
92
+ test('A FAILED APPLY: skipped until the round\'s updated_at moves', async () => {
93
+ const state = { tweakFailed: new Map([['901', 't1']]) };
94
+ const api = (updated) => fakeApi({ pages: [submitted('landing:index', 901)], rounds: { 901: round(901, { updated_at: updated }) } });
95
+ const same = await pick(api('t1'), state);
96
+ assert.deepEqual(same.skipped.map((s) => s.code), ['round_failed_unchanged']);
97
+ assert.equal((await pick(api('t2'), state)).round.task.id, 901, 'the artist edited or resubmitted it');
98
+ });
99
+
100
+ test('BELOW METIC: reported once, then quiet', async () => {
101
+ const state = {};
102
+ const api = fakeApi({ rank: 'thetes', pages: [submitted('landing:index', 901)], rounds: { 901: round(901) } });
103
+ const first = await pick(api, state);
104
+ assert.equal(first.code, 'lane_rank_too_low');
105
+ assert.equal(first.quiet, false);
106
+ const second = await pick(api, state);
107
+ assert.equal(second.code, 'lane_rank_too_low');
108
+ assert.equal(second.quiet, true, 'the lane stops saying it');
109
+ });
110
+
111
+ test('the outcome is read from the ledger: a held pass is the success, a released round a halt', () => {
112
+ assert.equal(lane.tweakOutcome({ task: { status: 'completed' }, grade: { passed: true } }).shape, 'waiting_for_artist');
113
+ assert.equal(lane.tweakOutcome({ task: { status: 'ready' }, grade: null }).failed, true);
114
+ assert.equal(lane.tweakOutcome({ task: { status: 'completed' }, grade: { passed: false } }).ok, false);
115
+ });
116
+
117
+ // ---- the real iteration() ---------------------------------------------------
118
+
119
+ const goodGauge = () => ({ decision: { go: true, unknown: false, sleepUntil: null, reason: 'headroom' } });
120
+ const openFence = (goals = [{ goal_id: 1000119 }]) => ({ raw: { enabled: true, paused_reason: null, goals }, graderBypassed: false });
121
+ const aTask = { id: 4242, title: 'Do the thing', kind: 'feature', description: 'd'.repeat(300), goal_id: 1000119, touches: [] };
122
+ const opts = { goals: [1000119], maxTasks: 1, dryRun: false, model: 'm', timeoutMs: 1000 };
123
+
124
+ function harness({ api, fenceRead = openFence(), afterStatus = 'completed', afterGrade = { passed: true } } = {}) {
125
+ const calls = []; const rows = []; let picked = 0;
126
+ const after = { ...round(901, { status: afterStatus, updated_at: 't9' }), grade: afterGrade };
127
+ const deps = {
128
+ gauge: goodGauge, readFence: async () => fenceRead, api, state: runner.newRunnerState(),
129
+ record: (r) => { rows.push(r); return r; },
130
+ worktreeName: (id) => `autobongos-${id}-abc123`,
131
+ pickTask: async () => { picked++; return { task: aTask, goalId: 1000119, skipped: [] }; },
132
+ run: async (bin, args, o = {}) => { calls.push({ script: String(args[0]).split(/[\\/]/).pop(), args: args.slice(1), cwd: o.cwd || null }); return { ok: true, code: 0, stdout: '', stderr: '' }; },
133
+ spawnWorker: async (a) => { calls.push({ script: 'WORKER', prompt: a.prompt }); return { envelope: { structured_output: { outcome: 'shipped' } }, exitCode: 0, sessionId: 's' }; },
134
+ laneDeps: { readInventory: () => INV, readRound: async (_api, id) => (String(id) === '901' ? after : null) },
135
+ verifyDeps: { task: { id: 4242, status: 'shipped', updated_at: new Date().toISOString() }, probeArtifact: async () => ({ checked: true, onMain: true }), fileBlocker: async () => ({ filed: true }) },
136
+ };
137
+ return { deps, calls, rows, pickedCount: () => picked };
138
+ }
139
+
140
+ test('iteration: a submitted round is worked BEFORE ordinary work — marked claim, unattended prompt', async () => {
141
+ const h = harness({ api: fakeApi({ pages: [submitted('landing:index', 901)], rounds: { 901: round(901) } }) });
142
+ const row = await runner.iteration(opts, h.deps);
143
+ assert.equal(row.event, 'worked');
144
+ assert.equal(row.lane, 'tweak');
145
+ assert.equal(row.outcome, 'waiting_for_artist');
146
+ assert.equal(h.pickedCount(), 0, 'ordinary work waits for the next pass');
147
+ const claim = h.calls.find((c) => c.script === 'claim.js');
148
+ assert.deepEqual(claim.args, ['901', '--auto-pickup']);
149
+ const worker = h.calls.find((c) => c.script === 'WORKER');
150
+ assert.match(worker.prompt, /\/tweak --page landing:index --unattended/);
151
+ assert.match(worker.prompt, /Never pass --skip-grade/);
152
+ });
153
+
154
+ test('iteration: a halted round is remembered as failed at its new updated_at, with no blocker', async () => {
155
+ const h = harness({ api: fakeApi({ pages: [submitted('landing:index', 901)], rounds: { 901: round(901) } }), afterStatus: 'ready', afterGrade: null });
156
+ const row = await runner.iteration(opts, h.deps);
157
+ assert.equal(row.outcome, 'tweak_halted');
158
+ assert.equal(h.deps.state.tweakFailed.get('901'), 't9');
159
+ });
160
+
161
+ test('iteration: with nothing for the lane, an ordinary task is still picked', async () => {
162
+ const h = harness({ api: fakeApi({ pages: [] }) });
163
+ const row = await runner.iteration(opts, h.deps);
164
+ assert.equal(h.pickedCount(), 1);
165
+ assert.equal(row.task_id, 4242);
166
+ assert.equal(row.lane, undefined);
167
+ });
168
+
169
+ test('iteration: a fence refusing on GOALS alone still lets the lane run; the kill switch does not', async () => {
170
+ const api = fakeApi({ pages: [submitted('landing:index', 901)], rounds: { 901: round(901) } });
171
+ const goalsOnly = harness({ api, fenceRead: openFence([]) });
172
+ assert.equal((await runner.iteration(opts, goalsOnly.deps)).lane, 'tweak');
173
+
174
+ const empty = harness({ api: fakeApi({ pages: [] }), fenceRead: openFence([]) });
175
+ const r2 = await runner.iteration(opts, empty.deps);
176
+ assert.equal(r2.event, 'fenced', 'no round: the goal refusal is reported as before');
177
+ assert.equal(empty.pickedCount(), 0);
178
+
179
+ const killed = harness({ api, fenceRead: { raw: { enabled: false }, graderBypassed: false } });
180
+ const r3 = await runner.iteration(opts, killed.deps);
181
+ assert.equal(r3.code, 'kill_switch');
182
+ assert.deepEqual(killed.calls, []);
183
+
184
+ const offLane = harness({ api: fakeApi({ settings: { ...ON, mode: 'off' }, pages: [submitted('landing:index', 901)], rounds: { 901: round(901) } }), fenceRead: openFence([]) });
185
+ assert.equal((await runner.iteration(opts, offLane.deps)).event, 'fenced', 'lane off: exactly the old fenced row');
186
+ });
187
+
188
+ test('iteration: the grade cap holds on a tweak worker — two failed grades release the round to a person', async () => {
189
+ const h = harness({ api: fakeApi({ pages: [submitted('landing:index', 901)], rounds: { 901: round(901) } }), afterGrade: { passed: false } });
190
+ let capGiven = null;
191
+ h.deps.gradeLedgerPath = () => null;
192
+ h.deps.laneDeps.gradeCapReader = async () => async () => ({ graded: 2, fails: 2, outages: 0, exhausted: true });
193
+ const spawn = h.deps.spawnWorker;
194
+ h.deps.spawnWorker = async (a) => { capGiven = a.capCheck; return spawn(a); };
195
+ const row = await runner.iteration(opts, h.deps);
196
+ assert.equal(typeof capGiven, 'function', 'the worker is handed the cap check');
197
+ const halt = h.calls.find((c) => c.script === 'tweak-halt.js');
198
+ assert.deepEqual(halt && halt.args, ['901', '--code', 'grade_failed']);
199
+ assert.equal(row.grade_cap_reached, true);
200
+ });
201
+
202
+ test('a backlog of rounds the lane must skip costs at most ten reads a pass', async () => {
203
+ let reads = 0;
204
+ const pages = Array.from({ length: 25 }, (_, i) => submitted('builders:core', 1000 + i));
205
+ const api = fakeApi({ pages });
206
+ await lane.pickRound({ api, state: {}, matchProtected, readInventory: () => INV, readRound: async () => { reads++; return round(1); } });
207
+ assert.equal(reads, 10);
208
+ });
@@ -128,4 +128,25 @@ await test('outside any claimed worktree no craft is guessed', async () => {
128
128
  assert.ok(r.out.includes(INTERACTION), 'and the rest of the hook still runs');
129
129
  });
130
130
 
131
+ // task 1004707: the project's LAWS, built by the SERVER's own helper (what the
132
+ // government port hands GET /me), run through the real hook.
133
+ const lawsLib = require('../modules/government/laws.js');
134
+ const LAW_BOOK = lawsLib.forSession([
135
+ { id: 11, text: 'Ask like a human', effect_on_ai: 'One short card with tap answers.', locks: [{ setting: 'verbosity', value: 'concise' }], repealed_at: null },
136
+ ]);
137
+
138
+ await test('a session on a project with laws is told the laws, first, in the server\'s own words', async () => {
139
+ const r = await runHook({ ...mePayload(), laws: { inject: LAW_BOOK.inject, contract: LAW_BOOK.contract, laws: LAW_BOOK.laws, locks: [] } });
140
+ assert.equal(r.code, 0);
141
+ assert.ok(r.out.includes('1. Ask like a human — One short card with tap answers.'), `the law reaches the session; stdout was: ${JSON.stringify(r.out.slice(0, 400))}`);
142
+ assert.ok(r.out.indexOf('Project laws') < r.out.indexOf(INTERACTION), 'ahead of the builder\'s own profile');
143
+ });
144
+
145
+ await test('a project with no laws sends no laws block', async () => {
146
+ const none = lawsLib.forSession([]);
147
+ const r = await runHook({ ...mePayload(), laws: { inject: none.inject, contract: none.contract, laws: [], locks: [] } });
148
+ assert.ok(!r.out.includes('Project laws'));
149
+ assert.ok(r.out.includes(INTERACTION), 'and the rest of the hook still runs');
150
+ });
151
+
131
152
  summary();
@@ -309,6 +309,24 @@ test('list: my connections, the count IS the array length, and paging is real',
309
309
  "…and applying the PUBLIC predicate here would erase your own connections from your own list");
310
310
  });
311
311
 
312
+ test('list: ?here=1 is the project cut — the same read, narrowed to this instance’s active builders', async () => {
313
+ expect(meActive(), result([]));
314
+ await call('GET', '/api/bongos/connections?here=1&limit=2&offset=4');
315
+ assert.ok(calls[1].text.includes(conn.HERE_SQL),
316
+ 'the hall tab’s cut (ADR 0337 D5.4): connections who also build on this project, joined on github_id');
317
+ assert.match(conn.HERE_SQL, /b\.status = 'active'/, 'a deactivated seat is not building here');
318
+ assert.deepEqual(calls[1].params, [ME.github_id, 2, 4],
319
+ 'a literal term with no new parameter — paging and the FANOUT_1 cap are the full list’s');
320
+ assert.match(calls[1].text, /\(pa\.profile_state = 'active' AND pa\.terms_accepted_at IS NOT NULL\)/,
321
+ 'still own scope on the consent floor — the cut narrows, it never swaps the predicate');
322
+
323
+ for (const q of ['', '?here=0', '?here=true', '?here=']) {
324
+ expect(meActive(), result([]));
325
+ await call('GET', `/api/bongos/connections${q}`);
326
+ assert.doesNotMatch(calls[1].text, /\bbuilders\b/, `"${q}" is the full list, unchanged`);
327
+ }
328
+ });
329
+
312
330
  test('list: the limit is clamped to FANOUT_1 however large a caller asks', async () => {
313
331
  expect(meActive(), result([]));
314
332
  await call('GET', '/api/bongos/connections?limit=999999');
@@ -128,8 +128,10 @@ const CRAFTS = ['building', 'artistry', 'ideating', 'governing'];
128
128
 
129
129
  test('THE ALWAYS-PRESENT LIST: one shared list, Builders first, then Collab and Profile, inside every mode', () => {
130
130
  const { craft } = boot();
131
- assert.deepEqual([...craft.ALWAYS_TABS], ['roster', 'collab', 'profile'],
132
- 'Builders (NAV id roster) heads the list (owner, 2026-10-01); Collab and Profile join it (owner, 2026-10-03)');
131
+ // task 1004685 (the living-city prototype): My agents joins as a PROPOSAL for the
132
+ // owner — every craft has agents, and round-3 board 6 pairs it with the city.
133
+ assert.deepEqual([...craft.ALWAYS_TABS], ['roster', 'collab', 'profile', 'my-agents'],
134
+ 'Builders (NAV id roster) heads the list (owner, 2026-10-01); Collab and Profile join it (owner, 2026-10-03); My agents (prototype, task 1004685)');
133
135
  for (const m of CRAFTS) {
134
136
  const tabs = craft.tabsFor(m);
135
137
  assert.ok(Array.isArray(tabs), `${m} has a named tab list`);
@@ -187,7 +189,7 @@ test('a mode never shows a tab the gate hid, and a later gate pass keeps the mod
187
189
  p.signIn('thetes');
188
190
  assert.ok(!p.showing().includes('watch'), 'a thetes in Governing does not get the metic-floored Watch tab');
189
191
  assert.ok(!p.headShowing('government'), 'nor its group head');
190
- assert.deepEqual(p.showing(), ['blockers', 'board-room', 'city', 'profile', 'collab', 'roster'], 'what a thetes board member in Governing sees');
192
+ assert.deepEqual(p.showing(), ['blockers', 'board-room', 'city', 'profile', 'my-agents', 'collab', 'roster'], 'what a thetes board member in Governing sees');
191
193
  p.signIn('archon');
192
194
  assert.ok(p.showing().includes('watch'), 'promoted, the gate lets it through and the mode keeps it');
193
195
  assert.ok(p.headShowing('government'), 'and its group head comes back with it, which only a mode pass after the gate can know');