@bongos/core 1.19.1080 → 1.20.1

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 (258) hide show
  1. package/.bongos-core.json +573 -188
  2. package/.claude/skills/planning-session/SKILL.md +6 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +34 -0
  5. package/clients/bongos-client/index.cjs +34 -0
  6. package/clients/bongos-client/index.d.ts +53 -4
  7. package/clients/bongos-client/index.mjs +34 -0
  8. package/docs/adr/0099-delayed-redacted-mirror-export.md +5 -1
  9. package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
  10. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +17 -0
  11. package/docs/adr/0176-private-repo-deploy-keys.md +2 -0
  12. package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
  13. package/docs/adr/0343-a-module-score-is-a-security-gate-then-an-average-of-visible-parts.md +2 -0
  14. package/docs/adr/0347-every-store-module-ships-a-how-to.md +135 -0
  15. package/docs/adr/0348-the-web-tier-may-look-read-only-at-what-an-owners-render-key-can-see.md +53 -0
  16. package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +106 -0
  17. package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +103 -0
  18. package/docs/adr/README.md +4 -0
  19. package/docs/api/openapi.json +995 -40
  20. package/docs/api-reference.md +33 -8
  21. package/docs/architecture.md +6 -2
  22. package/docs/copy-inventory.md +620 -553
  23. package/docs/copy-registry.json +1467 -820
  24. package/docs/file-map.md +2 -0
  25. package/docs/module-api-changelog.md +8 -0
  26. package/docs/modules-contract.md +1 -0
  27. package/docs/page-inventory.json +38 -4
  28. package/docs/page-readings.json +1674 -1566
  29. package/migrations/core_260_goals_working_area.sql +63 -0
  30. package/migrations/core_261_grade_attempts_verdicts.sql +24 -0
  31. package/migrations/core_262_drop_builder_box_blocked.sql +33 -0
  32. package/modules/agents/lib/validate.js +43 -0
  33. package/modules/autonomy/gauge.js +38 -2
  34. package/modules/grading/grader-subagent.js +37 -2
  35. package/modules/grading/grader-workers/reader.js +65 -5
  36. package/modules/hall-ui/public/approval-queue.css +33 -7
  37. package/modules/hall-ui/public/approval-queue.js +9 -2
  38. package/modules/hall-ui/public/atlas.html +1 -1
  39. package/modules/hall-ui/public/blockers.html +1 -1
  40. package/modules/hall-ui/public/board-room.html +1 -1
  41. package/modules/hall-ui/public/brand-holes.js +121 -0
  42. package/modules/hall-ui/public/collab.html +1 -1
  43. package/modules/hall-ui/public/collab.js +1 -1
  44. package/modules/hall-ui/public/copy-desk.html +1 -1
  45. package/modules/hall-ui/public/deploy.html +6 -1
  46. package/modules/hall-ui/public/deploy.js +22 -8
  47. package/modules/hall-ui/public/diagrams.html +1 -1
  48. package/modules/hall-ui/public/dom-utils.js +20 -0
  49. package/modules/hall-ui/public/drachmae.html +1 -1
  50. package/modules/hall-ui/public/fleet.html +1 -1
  51. package/modules/hall-ui/public/gate.html +1 -1
  52. package/modules/hall-ui/public/goals.html +1 -1
  53. package/modules/hall-ui/public/government.html +1 -1
  54. package/modules/hall-ui/public/idea.html +1 -1
  55. package/modules/hall-ui/public/idea.js +28 -0
  56. package/modules/hall-ui/public/ideas.html +1 -1
  57. package/modules/hall-ui/public/ideas.js +19 -3
  58. package/modules/hall-ui/public/index.html +5 -1
  59. package/modules/hall-ui/public/modules.html +1 -1
  60. package/modules/hall-ui/public/primer.html +1 -1
  61. package/modules/hall-ui/public/profile-nudge.js +21 -4
  62. package/modules/hall-ui/public/profile.html +1 -1
  63. package/modules/hall-ui/public/profile.js +1 -1
  64. package/modules/hall-ui/public/project-settings.html +1 -1
  65. package/modules/hall-ui/public/ranks.html +1 -1
  66. package/modules/hall-ui/public/roadmap.html +1 -1
  67. package/modules/hall-ui/public/roster.html +1 -1
  68. package/modules/hall-ui/public/sessions.html +1 -1
  69. package/modules/hall-ui/public/settings.html +19 -30
  70. package/modules/hall-ui/public/settings.js +24 -177
  71. package/modules/hall-ui/public/settings.states.json +1 -1
  72. package/modules/hall-ui/public/shell.js +4 -2
  73. package/modules/hall-ui/public/studio.css +14 -8
  74. package/modules/hall-ui/public/studio.html +5 -4
  75. package/modules/hall-ui/public/task.html +1 -1
  76. package/modules/hall-ui/public/thinking.css +6 -3
  77. package/modules/hall-ui/public/thinking.html +1 -1
  78. package/modules/hall-ui/public/tweak-editor-lib.js +82 -1
  79. package/modules/hall-ui/public/tweak-editor.css +142 -11
  80. package/modules/hall-ui/public/tweak-editor.html +25 -9
  81. package/modules/hall-ui/public/tweak-editor.js +243 -10
  82. package/modules/hall-ui/public/watch.html +1 -1
  83. package/modules/hall-ui/public/work.html +1 -1
  84. package/modules/hall-ui/records/approval-queue.md +6 -0
  85. package/modules/hall-ui/records/tweak-editor.md +6 -0
  86. package/modules/ideas/ratify.js +36 -8
  87. package/modules/ideas/routes/ratify-goal.js +6 -0
  88. package/modules/lifecycle/db-analytics.js +79 -10
  89. package/modules/lifecycle/db-claims.js +48 -1
  90. package/modules/lifecycle/db-deps-criteria.js +94 -40
  91. package/modules/lifecycle/db-goals.js +4 -1
  92. package/modules/lifecycle/db-grade.js +9 -1
  93. package/modules/lifecycle/db-relevance-flags.js +183 -0
  94. package/modules/lifecycle/db-versions.js +4 -3
  95. package/modules/lifecycle/db.js +19 -0
  96. package/modules/lifecycle/done-when.js +5 -3
  97. package/modules/lifecycle/est-advisory.js +31 -4
  98. package/modules/lifecycle/goal-task-relevance-advisory.js +153 -0
  99. package/modules/lifecycle/goal-task-relevance-judge.js +226 -0
  100. package/modules/lifecycle/migrations/lifecycle_014_goal_task_relevance_flags.sql +82 -0
  101. package/modules/lifecycle/module.json +2 -1
  102. package/modules/lifecycle/routes/claims.js +8 -1
  103. package/modules/lifecycle/routes/goal-task-relevance.js +72 -0
  104. package/modules/lifecycle/routes/goals.js +1 -1
  105. package/modules/lifecycle/routes/tasks.js +27 -24
  106. package/modules/lifecycle/routes/visuals.js +9 -0
  107. package/modules/lifecycle/ship-preflight.js +7 -11
  108. package/modules/lifecycle/task-visuals.js +109 -5
  109. package/modules/npm-release/module.json +2 -1
  110. package/modules/npm-release/preview/commands.js +81 -0
  111. package/modules/npm-release/preview/divert.js +95 -0
  112. package/modules/npm-release/preview/env.js +78 -0
  113. package/modules/npm-release/preview/proxy.js +107 -0
  114. package/modules/npm-release/preview/runtime.js +83 -0
  115. package/modules/npm-release/preview/supervisor.js +175 -0
  116. package/modules/npm-release/public/work.js +65 -1
  117. package/modules/npm-release/routes/preview.js +133 -0
  118. package/modules/provisioning/app-status.js +83 -0
  119. package/modules/provisioning/migrations/provisioning_028_render_standup.sql +46 -0
  120. package/modules/provisioning/module.json +4 -2
  121. package/modules/provisioning/pollers/app-liveness.js +110 -0
  122. package/modules/provisioning/provisioning.js +5 -5
  123. package/modules/provisioning/render-lookup.js +68 -0
  124. package/modules/provisioning/render-standup.js +134 -0
  125. package/modules/provisioning/routes/render-standup.js +165 -0
  126. package/modules/provisioning/starter-bundles.js +4 -1
  127. package/modules/public-landing/public/account-private.states.json +14 -0
  128. package/modules/public-landing/public/account.html +513 -0
  129. package/modules/public-landing/public/account.probes.json +37 -0
  130. package/modules/public-landing/public/account.states.json +16 -0
  131. package/modules/public-landing/public/index.html +5 -2
  132. package/modules/public-landing/public/projects.html +661 -18
  133. package/modules/public-landing/public/projects.states.json +8 -1
  134. package/modules/render-deploy/deploys.js +203 -0
  135. package/modules/render-deploy/migrations/render_deploy_001_app.sql +22 -0
  136. package/modules/render-deploy/module.json +25 -0
  137. package/modules/render-deploy/public/deploy.css +10 -0
  138. package/modules/render-deploy/public/deploy.js +249 -0
  139. package/modules/render-deploy/render.js +56 -0
  140. package/modules/render-deploy/routes/act.js +88 -0
  141. package/modules/render-deploy/routes/app.js +65 -0
  142. package/modules/render-deploy/routes/history.js +38 -0
  143. package/modules/specialities/db.js +27 -8
  144. package/modules/specialities/migrations/specialities_003_skills.sql +46 -0
  145. package/modules/specialities/routes/specialities.js +51 -3
  146. package/modules/specialities/skills.js +94 -0
  147. package/modules/specialities/specialities.js +56 -9
  148. package/modules/ui-design/kit/fixtures/me-cross-project-private.json +12 -0
  149. package/modules/ui-design/kit/fixtures/me__cross-project.json +15 -0
  150. package/modules/ui-design/kit/fixtures/provisioning-instance-render.json +39 -0
  151. package/modules/ui-design/kit/lib.js +3 -1
  152. package/modules/ui-design/kit/serve.js +96 -0
  153. package/package-lock.json +2 -2
  154. package/package.json +1 -1
  155. package/release-notes.json +173 -0
  156. package/scripts/gds/agents-sync.js +13 -3
  157. package/scripts/gds/autobongos-run.js +80 -2
  158. package/scripts/gds/autobongos-service.cmd +12 -0
  159. package/scripts/gds/copy-apply.js +14 -0
  160. package/scripts/gds/dev-box-guard.js +3 -2
  161. package/scripts/gds/fitness.js +9 -0
  162. package/scripts/gds/grade-correlation-audit.js +42 -12
  163. package/scripts/gds/grade-replay.js +1 -1
  164. package/scripts/gds/provision-core-upgrade.js +38 -7
  165. package/scripts/gds/provision-pin-key.js +176 -0
  166. package/scripts/gds/provision-render.js +189 -0
  167. package/scripts/gds/provision-teardown.js +9 -2
  168. package/scripts/gds/provision-units.js +31 -0
  169. package/scripts/gds/provision.js +12 -12
  170. package/scripts/gds/publish-manifest.js +1 -0
  171. package/scripts/gds/render-api.js +197 -0
  172. package/scripts/gds/render-payload.js +84 -0
  173. package/scripts/gds/run-unit-tests.js +10 -0
  174. package/scripts/gds/ship-finish.js +19 -20
  175. package/scripts/gds/smoke-dependencies.sh +37 -8
  176. package/scripts/gds/status.js +12 -4
  177. package/scripts/gds/upgrade.js +2 -2
  178. package/scripts/public-mirror-export.js +7 -1
  179. package/src/bongos/module-scope-map.js +13 -0
  180. package/src/bongos/serve-internal.js +3 -1
  181. package/src/module-api.js +10 -1
  182. package/src/platform-server.js +39 -0
  183. package/tests/account_privacy_flags.mjs +10 -6
  184. package/tests/agents_authoring.mjs +15 -1
  185. package/tests/agents_sync.mjs +106 -5
  186. package/tests/agents_validate.mjs +45 -0
  187. package/tests/api_path_404.mjs +6 -0
  188. package/tests/autobongos_cadence.mjs +7 -0
  189. package/tests/autobongos_loop.mjs +136 -1
  190. package/tests/autonomy_gauge.mjs +62 -0
  191. package/tests/blocker_hall_live_proof.mjs +275 -0
  192. package/tests/claim_gate_ci_unblock.mjs +116 -0
  193. package/tests/claim_gate_rebase.mjs +15 -3
  194. package/tests/collab_page.mjs +10 -0
  195. package/tests/conductor_main_e2e.mjs +97 -0
  196. package/tests/core_262_drop_box_blocked_db.mjs +143 -0
  197. package/tests/core_upgrade_runner.mjs +1 -0
  198. package/tests/dependency_writes_atomic.mjs +191 -0
  199. package/tests/deploy_page_projects.mjs +2 -1
  200. package/tests/effective_visibility_predicate.mjs +15 -0
  201. package/tests/est_advisory.mjs +27 -0
  202. package/tests/goal_map_page.mjs +26 -0
  203. package/tests/goal_task_relevance.mjs +518 -0
  204. package/tests/goal_working_area.mjs +146 -0
  205. package/tests/grade_attempts.mjs +61 -0
  206. package/tests/grade_attribution.mjs +23 -0
  207. package/tests/grade_correlation_audit.mjs +54 -1
  208. package/tests/grader_reader_lens.mjs +55 -2
  209. package/tests/grader_root_outage.mjs +6 -1
  210. package/tests/grader_subagent_tools_arg.mjs +102 -0
  211. package/tests/hall_approval_queue.mjs +54 -2
  212. package/tests/hall_settings_world.mjs +3 -1
  213. package/tests/hall_tweak_editor.mjs +316 -11
  214. package/tests/hub_account_page.mjs +394 -0
  215. package/tests/idea_detail_page.mjs +64 -0
  216. package/tests/idea_goal_ratification.mjs +64 -0
  217. package/tests/idea_spark_hall.mjs +60 -9
  218. package/tests/ideator_full_idea_shapes_space_proof.mjs +1 -1
  219. package/tests/landing_page.mjs +6 -3
  220. package/tests/module-scope-map.mjs +31 -8
  221. package/tests/module_api.mjs +1 -0
  222. package/tests/module_loader.mjs +1 -1
  223. package/tests/nav_permission_atoms.mjs +1 -1
  224. package/tests/no_phantom_mirror_workflow.mjs +24 -0
  225. package/tests/npm_release_preview_commands.mjs +89 -0
  226. package/tests/npm_release_preview_divert.mjs +164 -0
  227. package/tests/npm_release_preview_env.mjs +100 -0
  228. package/tests/npm_release_preview_proxy.mjs +162 -0
  229. package/tests/npm_release_preview_routes.mjs +208 -0
  230. package/tests/npm_release_preview_supervisor.mjs +203 -0
  231. package/tests/pin_write_key.mjs +383 -0
  232. package/tests/planning_session_skill.mjs +27 -0
  233. package/tests/platform_boot.mjs +71 -0
  234. package/tests/profile_nudge_links.mjs +86 -0
  235. package/tests/profile_ui_cross_project.mjs +1 -1
  236. package/tests/projects_hub.mjs +2 -2
  237. package/tests/projects_hub_app_status.mjs +249 -0
  238. package/tests/projects_hub_app_step.mjs +282 -52
  239. package/tests/projects_hub_render_connect.mjs +295 -0
  240. package/tests/provision_render.mjs +362 -0
  241. package/tests/provision_settings_apply.mjs +62 -0
  242. package/tests/provisioning_app_status.mjs +204 -0
  243. package/tests/provisioning_render_route.mjs +336 -0
  244. package/tests/provisioning_settings_apply.mjs +1 -1
  245. package/tests/provisioning_teardown_intent.mjs +2 -2
  246. package/tests/render_api.mjs +153 -0
  247. package/tests/render_check.mjs +2 -1
  248. package/tests/render_deploy.mjs +495 -0
  249. package/tests/ship_preflight.mjs +61 -8
  250. package/tests/smoke_dependencies_witness.mjs +46 -0
  251. package/tests/speciality_skills.mjs +214 -0
  252. package/tests/studio_room_height.mjs +70 -0
  253. package/tests/task_detail_includes.mjs +10 -0
  254. package/tests/task_visuals_instance_root.mjs +150 -0
  255. package/tests/tweak_batch_apply.mjs +21 -0
  256. package/tests/ui_design_kit.mjs +36 -4
  257. package/tests/upgrade.mjs +1 -1
  258. package/tests/upgrade_persist_pin.mjs +24 -0
@@ -0,0 +1,63 @@
1
+ -- core_260_goals_working_area.sql - a working area's number becomes a FIELD on
2
+ -- its goal, not a substring of the goal's title (task 1004057, ADR 0264 + 0308).
3
+ --
4
+ -- WHY. ADR 0264 (and 0308, which added the eleventh) numbers the working areas,
5
+ -- and the board is one goal per area. Until now the number lived only as prose at
6
+ -- the front of goals.title ("Working area 6 - ..."), so anything that wanted to
7
+ -- group, sort or label by area had to regex a human title, and would break the
8
+ -- day an area was retitled (goal 1000095 already was once). Worse, the goal row
9
+ -- carries two integers that LOOK like the area number and are not: goals.id, and
10
+ -- goals.sort_order - the owner's priority order (migration 187), 0-based, which
11
+ -- agrees with the area number for exactly one of the eleven areas (area 6).
12
+ --
13
+ -- NOT sort_order. That column has a stated, different job and the owner reorders
14
+ -- with it. Nothing here reads or writes it.
15
+ --
16
+ -- THE BACKFILL IS BY THE TITLE'S LEADING NUMBER, ONCE. The existing rows were
17
+ -- created by the title convention, so the convention is how they are identified -
18
+ -- exactly once, here - and the column carries the fact from now on. It matches
19
+ -- the leading "Working area N" only and ignores the rest of the title: the ADR
20
+ -- writes some areas with a trailing parenthetical the DB titles do not carry
21
+ -- (area 4 "(staging of updates)", area 6 "(incl. agents)"), so the two strings
22
+ -- are not byte-identical and must not be compared whole. It does NOT key off goal
23
+ -- ids: those are this instance's rows, and on any other instance the same ids are
24
+ -- unrelated goals. A goal with no such title stays NULL - every non-area goal,
25
+ -- which is the overwhelming majority, and every goal on an instance that does not
26
+ -- use working areas.
27
+ --
28
+ -- NO UNIQUE INDEX per version, deliberately (the core_234 lesson): goals has no
29
+ -- unique constraint on (version_id, title), so a database holding two goals
30
+ -- titled for the same area would fail the index and block the deploy. The CHECK
31
+ -- keeps the value a positive number and nothing more, so a twelfth area needs no
32
+ -- schema change.
33
+ --
34
+ -- Forward-safe: one additive nullable column with no default. Old code on the new
35
+ -- schema neither sees nor needs it (every reader SELECTs an explicit column
36
+ -- list); a rollback leaves the column populated and unread.
37
+
38
+ BEGIN;
39
+
40
+ ALTER TABLE goals
41
+ ADD COLUMN IF NOT EXISTS working_area smallint;
42
+
43
+ DO $$
44
+ BEGIN
45
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'goals_working_area_positive') THEN
46
+ ALTER TABLE goals
47
+ ADD CONSTRAINT goals_working_area_positive CHECK (working_area IS NULL OR working_area > 0);
48
+ END IF;
49
+ END $$;
50
+
51
+ COMMENT ON COLUMN goals.working_area IS
52
+ 'The ADR 0264 working-area number this goal IS, or NULL for any other goal. Not sort_order (the owner''s priority order) and not the id (task 1004057).';
53
+
54
+ UPDATE goals
55
+ SET working_area = substring(title FROM '^Working area ([0-9]{1,3})([^0-9]|$)')::smallint
56
+ WHERE working_area IS NULL
57
+ AND title ~ '^Working area [0-9]{1,3}([^0-9]|$)'
58
+ AND substring(title FROM '^Working area ([0-9]{1,3})([^0-9]|$)')::int > 0;
59
+
60
+ INSERT INTO schema_migrations (version) VALUES ('core_260_goals_working_area')
61
+ ON CONFLICT (version) DO NOTHING;
62
+
63
+ COMMIT;
@@ -0,0 +1,24 @@
1
+ -- core_261_grade_attempts_verdicts.sql -- task 1004292 (found while building
2
+ -- task 1002843, the grader error-correlation audit).
3
+ --
4
+ -- WHY. Only a task's LATEST grade keeps its per-worker verdicts: task_grades is
5
+ -- one row per task, and grade_attempts (core 064) stores only passed + score per
6
+ -- attempt. So every objection a worker raised in an EARLIER round, the round the
7
+ -- builder then fixed, is gone by the time the audit reads it, and a worker that
8
+ -- mostly objects early looks near-silent.
9
+ --
10
+ -- WHAT. One nullable jsonb column on the append-only attempt log:
11
+ -- [{ "kind": "hacker", "verdict": "concerns", "error": null,
12
+ -- "severities": ["major", "minor"] }, ...]
13
+ -- kind, verdict, error code and the SEVERITIES of the worker's issues, and no
14
+ -- prose: a review's summary is not kept beyond the latest grade. NULL for every
15
+ -- attempt made before this column (and for one whose best-effort write failed);
16
+ -- readers fall back to the task's final grade.
17
+ --
18
+ -- Additive and nullable: no default, no rewrite, safe against a rolled-back
19
+ -- release that never names it. Idempotent.
20
+ BEGIN;
21
+
22
+ ALTER TABLE grade_attempts ADD COLUMN IF NOT EXISTS worker_verdicts jsonb;
23
+
24
+ COMMIT;
@@ -0,0 +1,33 @@
1
+ -- core_262_drop_builder_box_blocked.sql — drop the four builders.box_blocked* columns,
2
+ -- the last stored piece of the dev box (task 1004360, goal 1000120; ADR 0346).
3
+ --
4
+ -- Migration 104 added them so an Archon could bar one builder from opening a dev box:
5
+ -- box_blocked, box_blocked_reason, box_blocked_at and box_blocked_by (a FK back to
6
+ -- builders). The box is gone — its module (task 1003892), its tables and session
7
+ -- source (core_258, task 1003895) — so nothing sets or honours the block any more.
8
+ --
9
+ -- WHY A RELEASE LATER THAN core_258, not in it. A deploy runs migrations while the
10
+ -- PREVIOUS server is still up, and a failed upgrade rolls the code back but never the
11
+ -- schema. Every core before task 1003895 SELECTs these columns in getBuilderById, so
12
+ -- dropping them in the same release would have broken every builder read for as long
13
+ -- as that code ran. 1003895 removed the read; its first release was 1.19.1074.
14
+ --
15
+ -- forward-unsafe: drops four builders columns. Safe now because no reader is left:
16
+ -- task 1003895 removed the only core read (getBuilderById) and task 1003892 the only
17
+ -- writer (the dev-box module's block route), and on 2026-09-29 every active instance
18
+ -- in provisioning_instances served core 1.19.1076 or later, past the first release
19
+ -- (1.19.1074) without that read. A rollback to such a core never names the columns.
20
+ --
21
+ -- CASCADE is deliberately absent: if a view or anything else still depended on a
22
+ -- column, the DROP fails loudly rather than silently taking it too. Dropping
23
+ -- box_blocked_by drops its foreign key with it. Idempotent: every clause is IF EXISTS.
24
+
25
+ BEGIN;
26
+
27
+ ALTER TABLE builders
28
+ DROP COLUMN IF EXISTS box_blocked,
29
+ DROP COLUMN IF EXISTS box_blocked_reason,
30
+ DROP COLUMN IF EXISTS box_blocked_at,
31
+ DROP COLUMN IF EXISTS box_blocked_by;
32
+
33
+ COMMIT;
@@ -141,6 +141,10 @@ function isSafeScopePath(p) {
141
141
  * `null` on any failure — a rejected definition must never hand back a row
142
142
  * something downstream could insert.
143
143
  */
144
+ // The fields a definition may carry: exactly the normalized row below. Anything
145
+ // else is refused as unknown_field rather than dropped (task 1004007).
146
+ const DEFINITION_KEYS = ['name', 'title', 'persona', 'trigger_type', 'trigger_spec', 'model_tier', 'scope_modules', 'scope_paths'];
147
+
144
148
  function validateAgentDefinition(obj, opts = {}) {
145
149
  const errors = [];
146
150
  const E = (code, field, message) => errors.push({ code, field, message });
@@ -172,6 +176,28 @@ function validateAgentDefinition(obj, opts = {}) {
172
176
  // why its absence refuses rather than admits.
173
177
  const isProtectedPath = safeGet(options, 'isProtectedPath');
174
178
 
179
+ // --- unknown keys, fail-closed (task 1004007) ------------------------------
180
+ // The normalized row names exactly DEFINITION_KEYS, so anything else would be
181
+ // accepted and then silently DROPPED, which turned a file's top-level
182
+ // `touches:` into "fires on every ship" with ok:true and no error. A key this
183
+ // validator does not know is refused BY NAME instead: a typo is an error, not
184
+ // a default. The write routes already refuse unknown body keys (strict schema);
185
+ // this closes the file path, which reaches here with no schema in front.
186
+ // Enumerated under a guard, like every other read here: a Proxy's ownKeys
187
+ // trap can throw, and the never-throws contract covers this line too. An
188
+ // object whose keys cannot be listed cannot be checked, so it is refused.
189
+ let unknown = [];
190
+ try {
191
+ unknown = Object.keys(obj).filter((k) => !DEFINITION_KEYS.includes(k));
192
+ } catch (err) {
193
+ E('keys_unreadable', '(root)',
194
+ `The definition's fields could not be listed (${(err && err.message) || 'enumeration threw'}); it must be plain data. Refused rather than checked partially.`);
195
+ }
196
+ if (unknown.length > 0) {
197
+ E('unknown_field', unknown.join(', '),
198
+ `${unknown.map((k) => JSON.stringify(k)).join(', ')} ${unknown.length === 1 ? 'is not a field' : 'are not fields'} of an agent definition and would be dropped. Fields: ${DEFINITION_KEYS.join(', ')}. A trigger parameter (event, touches, sink, cadence) belongs inside trigger_spec; in a .claude/agents file write it at the top level and it is folded in.`);
199
+ }
200
+
175
201
  // --- name ----------------------------------------------------------------
176
202
  const name = safeGet(obj, 'name');
177
203
  if (!isNonEmptyString(name) || !NAME_RE.test(name) || name.length > NAME_MAX) {
@@ -268,6 +294,23 @@ function validateAgentDefinition(obj, opts = {}) {
268
294
  }
269
295
  }
270
296
 
297
+ // --- the touches filter, fail-closed (task 1004007) -------------------------
298
+ // The event router reads a filter that is not a list of non-empty strings as
299
+ // ABSENT, and an absent filter FIRES ON EVERY SHIP. That is the right runtime
300
+ // default, and exactly why a malformed one must be refused here: "touches:
301
+ // docs/**" parses to a string, and storing it would arm the widest possible
302
+ // agent while its author believes it is narrow.
303
+ if (spec) {
304
+ for (const key of ['touches', 'touches_globs']) {
305
+ const raw = safeGet(spec, key);
306
+ if (raw === undefined || raw === null) continue;
307
+ if (!Array.isArray(raw) || raw.length === 0 || !raw.every(isNonEmptyString)) {
308
+ E('trigger_touches_invalid', `trigger_spec.${key}`,
309
+ `trigger_spec.${key}: must be a non-empty list of path globs; received ${JSON.stringify(raw)}. The event router treats anything else as NO filter, which fires on every ship. In a .claude/agents file write it as a list (${key}: [docs/**] or one "- glob" per line).`);
310
+ }
311
+ }
312
+ }
313
+
271
314
  // --- model tier -----------------------------------------------------------
272
315
  const rawTier = safeGet(obj, 'model_tier');
273
316
  const modelTier = rawTier === undefined || rawTier === null ? 'default' : rawTier;
@@ -36,6 +36,12 @@ const WEEK_SECONDS = 7 * 24 * 60 * 60;
36
36
  // number is least trustworthy.
37
37
  const DEFAULT_STALE_AFTER_S = 1800;
38
38
 
39
+ // WINDOW_PERIOD_S — how long each window lasts, used ONLY to step a retired
40
+ // boundary forward into the present (task 1004376). Never used to decide
41
+ // capacity: these windows roll from first use, so a computed boundary is an
42
+ // estimate and only ever bounds how far back a cap counts.
43
+ const WINDOW_PERIOD_S = { five_hour: 5 * 3600, seven_day: 7 * 24 * 3600 };
44
+
39
45
  // Reserve = capacity deliberately left unspent, in percentage points.
40
46
  //
41
47
  // It exists for exactly two reasons, and neither is thrift:
@@ -163,8 +169,32 @@ function readGauge(raw, nowEpochS, config = {}) {
163
169
  return { used, resetsAt: resets, observedAt, ageS: nowEpochS - observedAt, derived: false };
164
170
  }
165
171
  // Aged out. Superseded ONLY if its window has actually turned over.
172
+ //
173
+ // ADVANCE TO THE CURRENT WINDOW, do not stand on the retired one (task
174
+ // 1004376). The retired reading's resets_at can be DAYS old — that is the
175
+ // whole premise of the derived path, since nothing refreshes the gauge on an
176
+ // unattended host. Reporting it as the baseline handed the caller an ancient
177
+ // mark, and its cap arrived pre-exhausted by work done days earlier: the
178
+ // runner derived headroom correctly and then held forever, which is the
179
+ // state task 1004178 existed to end.
180
+ //
181
+ // The exact boundary is unknowable — these windows roll from first use, not
182
+ // from a fixed clock — but it is BOUNDABLE: step forward whole periods from
183
+ // a boundary we did observe until we land inside the current one. Wrong by
184
+ // at most a partial window, and wrong in the direction of a more recent
185
+ // mark, which is the safe direction for something that bounds recent work.
166
186
  if (nowEpochS >= resets) {
167
- return { used: 0, resetsAt: resets, observedAt: resets, ageS: nowEpochS - resets, derived: true };
187
+ const period = WINDOW_PERIOD_S[w];
188
+ const start = Number.isFinite(period) && period > 0
189
+ ? resets + Math.floor((nowEpochS - resets) / period) * period
190
+ : resets;
191
+ return {
192
+ used: 0,
193
+ resetsAt: Number.isFinite(period) && period > 0 ? start + period : resets,
194
+ observedAt: start,
195
+ ageS: nowEpochS - start,
196
+ derived: true,
197
+ };
168
198
  }
169
199
  return null; // old, and its window is still running — no longer evidence
170
200
  };
@@ -254,8 +284,14 @@ function decideCapacity({ gauge, now, config = {} } = {}) {
254
284
  // cannot stand in for it either: usage only grows, so an under-reported value
255
285
  // makes the check MORE permissive, which is the wrong direction to be wrong in.
256
286
  // Bounding the work done since `derivedSince` is what replaces that pacing.
287
+ //
288
+ // The MOST RECENT boundary, not the oldest (task 1004376). A derived seven-day
289
+ // window starts up to a week back; counting from there would make the cap a
290
+ // one-time budget that never refills rather than a per-window bound, and the
291
+ // runner would hold for days. The five-hour boundary is the one that recurs,
292
+ // so the cap allows its budget again each window — bounded, and it recovers.
257
293
  const derivedSince = derived
258
- ? Math.min(...[g.fiveHour, g.sevenDay].filter((w) => w && w.derived).map((w) => w.observedAt))
294
+ ? Math.max(...[g.fiveHour, g.sevenDay].filter((w) => w && w.derived).map((w) => w.observedAt))
259
295
  : null;
260
296
  return { go: true, unknown: false, sleepUntil: null, derived, derivedSince, reason: `headroom (${bits.join(', ')}; ${detail})` };
261
297
  }
@@ -16,6 +16,14 @@ const { DEFAULT_GRADER_MODEL, api } = require('./grader-config.js');
16
16
  // through it; the Claude spawn is its default provider.
17
17
  const modelProvider = require('./model-provider.js');
18
18
 
19
+ // toolsArgForSpawn — pure, exported so tests/grader_subagent_tools_arg.mjs can
20
+ // pin it without a real spawn. See runClaudeCli's WIN32 EMPTY-ARG TRAP comment
21
+ // for the why: only an explicit empty request ('') needs the win32 rewrite —
22
+ // any other value (including the 'Read' default) passes through untouched.
23
+ function toolsArgForSpawn(tools, { shell }) {
24
+ return tools === '' && shell ? '""' : tools;
25
+ }
26
+
19
27
  // The `claude -p` spawn — the anthropic-cli ModelProvider. Body unchanged by
20
28
  // task 1002840; only its name moved, so runSubagent could become the port.
21
29
  function runClaudeCli({ prompt, opts = {} }) {
@@ -53,12 +61,38 @@ function runClaudeCli({ prompt, opts = {} }) {
53
61
  // 21 extra tools on the owner's Mac, a docs connector that can delete documents
54
62
  // among them — inside a session with no permission prompts, reading a diff
55
63
  // someone else wrote. Strict mode with no `--mcp-config` loads zero servers.
64
+ //
65
+ // `opts.tools` (task 1001691): every caller so far needed exactly `Read` (a
66
+ // panel worker judges a diff bundle it must be able to open), so that stayed
67
+ // the hardcoded default — but a caller with NOTHING to read (the ADR 0101
68
+ // relevance judge classifies one self-contained prompt string) can request
69
+ // `''`, which the CLI documents (`--help`, verified against the installed
70
+ // binary) as disabling every built-in tool outright — a stronger guarantee
71
+ // than any cwd/permission-mode narrowing: with no tool at all, a jailbroken
72
+ // reply has no channel to read a file back through, on this host or any
73
+ // other, absolute path or not.
74
+ //
75
+ // WIN32's EMPTY-ARG TRAP (round-4 hacker finding, empirically reproduced):
76
+ // this spawn runs `shell: true` on win32 (below, for the claude.cmd shim),
77
+ // and Node's shell path joins argv with spaces before handing the line to
78
+ // cmd.exe — an empty-string element is silently DROPPED in that join, so a
79
+ // bare `''` here would shift every arg after it, and the CLI would read
80
+ // `--strict-mcp-config` as `--tools`'s value: the exact opposite of "no
81
+ // tools", reopening this very hole. The two-character string `'""'` (an
82
+ // explicit empty-quoted token) survives the cmd.exe → CRT argv parse as a
83
+ // genuine empty string — the standard Windows shell workaround, verified
84
+ // with a real spawn (`node -e` reading back its own argv) before landing
85
+ // this fix. On POSIX, `shell: false` means these args reach `execve()`
86
+ // directly with no shell in between, so the quoting would NOT be
87
+ // interpreted — a plain `''` there stays a plain empty string.
88
+ const usesShell = process.platform === 'win32';
89
+ const tools = toolsArgForSpawn(opts.tools ?? 'Read', { shell: usesShell });
56
90
  const args = [
57
91
  '-p',
58
92
  '--print',
59
93
  '--model', model,
60
94
  '--output-format', 'json',
61
- '--tools', 'Read',
95
+ '--tools', tools,
62
96
  '--strict-mcp-config',
63
97
  '--disable-slash-commands',
64
98
  '--permission-mode', permissionMode,
@@ -74,7 +108,7 @@ function runClaudeCli({ prompt, opts = {} }) {
74
108
  // shell:false (the default, and the proven-working path). args carry no
75
109
  // user input (the prompt goes in via stdin), so shell injection is a
76
110
  // non-issue. windowsHide suppresses the transient console window.
77
- shell: process.platform === 'win32',
111
+ shell: usesShell,
78
112
  windowsHide: true,
79
113
  // MAS Phase 3 (#427): mark this child as a subagent so the Conductor
80
114
  // UserPromptSubmit hook (.claude/hooks/conductor.js) bails instead of
@@ -318,4 +352,5 @@ module.exports = {
318
352
  extractCostUsd,
319
353
  runSubagent,
320
354
  runSubagentCached,
355
+ toolsArgForSpawn,
321
356
  };
@@ -26,12 +26,68 @@
26
26
  //
27
27
  // THE STANDARD IS WRITTEN DOWN. docs/voice.md (task 1003976) is the rubric, and
28
28
  // its own rule is that a finding citing no rule id is not a finding. The guide
29
- // splits HARD rules (V1-V5, checkable — a lens may assert a break) from
30
- // JUDGEMENT rules (V6-V11, recognisable but not provable — a lens may only FLAG
29
+ // splits HARD rules (Part A, checkable — a lens may assert a break) from
30
+ // JUDGEMENT rules (Part B, recognisable but not provable — a lens may only FLAG
31
31
  // a candidate and must quote the line). That split is reproduced in the prompt
32
32
  // below, because it is what keeps an advisory lens from recording taste as fact.
33
-
33
+ //
34
+ // THE SPLIT IS READ FROM THE GUIDE, not restated (task 1004075). The prompt used
35
+ // to carry the ranges as literals (V1-V5 / V6-V11), so a twelfth rule, or
36
+ // splitting V4, fell outside both ranges in the only automated consumer and the
37
+ // lens was silently handed the wrong contract: the guide's own Part D says it
38
+ // grows by adding rules, and it could not without editing this file.
39
+ // voiceRuleClasses() reads the ids under "# Part A" and "# Part B" at load; if
40
+ // the guide cannot be read, the prompt names the two Parts instead of numbers,
41
+ // which is still true. tests/grader_reader_lens.mjs fails when a rule id in the
42
+ // guide sits under neither Part.
43
+
44
+ const fs = require('fs');
45
+ const path = require('path');
34
46
  const { buildWorkerPrompt, LLM_PROJECT } = require('./worker-prompt');
47
+
48
+ const VOICE_GUIDE = path.join(__dirname, '..', '..', '..', 'docs', 'voice.md');
49
+
50
+ // The rule ids under each Part of the guide, in file order. A rule heading is
51
+ // "## V<n> — ...". Anything under neither Part A nor Part B is returned as
52
+ // unclassified, which the test refuses: every rule must be one or the other.
53
+ function voiceRuleClasses(text) {
54
+ const out = { hard: [], judgement: [], unclassified: [] };
55
+ let part = null;
56
+ for (const line of String(text || '').split(/\r?\n/)) {
57
+ const top = /^# Part ([A-Z])\b/.exec(line);
58
+ if (top) { part = top[1]; continue; }
59
+ if (/^# /.test(line)) { part = null; continue; }
60
+ const rule = /^## (V\d+)\b/.exec(line);
61
+ if (!rule) continue;
62
+ (part === 'A' ? out.hard : part === 'B' ? out.judgement : out.unclassified).push(rule[1]);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ // "V1-V5", "V6-V11, V13": contiguous runs collapse so the prompt reads as it did.
68
+ function formatRuleIds(ids) {
69
+ const nums = [...new Set(ids.map((id) => Number(String(id).slice(1))))].filter(Number.isInteger).sort((a, b) => a - b);
70
+ const runs = [];
71
+ for (const n of nums) {
72
+ const last = runs[runs.length - 1];
73
+ if (last && n === last[1] + 1) last[1] = n; else runs.push([n, n]);
74
+ }
75
+ return runs.map(([a, b]) => (a === b ? `V${a}` : `V${a}-V${b}`)).join(', ');
76
+ }
77
+
78
+ // What the prompt says in the brackets for each class. Falls back to naming the
79
+ // Part when the guide is unreadable or a Part is empty: never a stale number.
80
+ function ruleIdsText(classes) {
81
+ const hard = classes && classes.hard.length ? formatRuleIds(classes.hard) : 'every rule under "Part A — Hard rules"';
82
+ const judgement = classes && classes.judgement.length ? formatRuleIds(classes.judgement) : 'every rule under "Part B — Judgement rules"';
83
+ return { hard, judgement };
84
+ }
85
+
86
+ function readVoiceClasses() {
87
+ try { return voiceRuleClasses(fs.readFileSync(VOICE_GUIDE, 'utf8')); } catch { return null; }
88
+ }
89
+
90
+ const RULE_IDS = ruleIdsText(readVoiceClasses());
35
91
  const { parseCommon } = require('./worker-verdict');
36
92
  const { extractJson } = require('./extract-json');
37
93
 
@@ -59,8 +115,8 @@ Your ONLY job is user-facing copy. You do NOT score whether the description was
59
115
 
60
116
  The guide splits its rules in two, and the split governs what you may say:
61
117
 
62
- - **Hard rules (V1-V5)** can be checked. A line either breaks one or it does not. You MAY assert a break.
63
- - **Judgement rules (V6-V11)** name a failure a person can recognise but a checker cannot prove. You may **flag a candidate** and must quote the line and the rule id. You may NEVER assert that the line is wrong. The artist decides.
118
+ - **Hard rules (${RULE_IDS.hard})** can be checked. A line either breaks one or it does not. You MAY assert a break.
119
+ - **Judgement rules (${RULE_IDS.judgement})** name a failure a person can recognise but a checker cannot prove. You may **flag a candidate** and must quote the line and the rule id. You may NEVER assert that the line is wrong. The artist decides.
64
120
 
65
121
  Set \`kind\` on every issue to \`"break"\` (hard rule, asserted) or \`"flag"\` (judgement rule, offered). A judgement-rule issue filed as a break is the single most damaging thing you can do here, because it records taste as fact.
66
122
 
@@ -213,6 +269,10 @@ module.exports = {
213
269
  ADVISORY_VERDICT,
214
270
  PROMPT_HEADER,
215
271
  buildPrompt,
272
+ voiceRuleClasses,
273
+ formatRuleIds,
274
+ ruleIdsText,
275
+ VOICE_GUIDE,
216
276
  extractJson,
217
277
  computeVerdict,
218
278
  };
@@ -63,9 +63,16 @@ html[data-theme="dark"] .aq-overlay { --aq-scrim: rgba(4, 5, 9, .42); }
63
63
  .aq-overlay[hidden] { display: none; }
64
64
  html.aq-open { overflow: hidden; }
65
65
 
66
+ /* FITS THE SCREEN (task 1004381). The artboard's 1180 by 820 is a ceiling, never
67
+ a floor: the dialog is also capped by the VIEWPORT (less the 24px gutters), not
68
+ only by its overlay, so no ancestor that becomes the fixed overlay's containing
69
+ block can make it wider or taller than the screen. Inside, the rendered page
70
+ shrinks first; the words column only narrows once the page is small. */
66
71
  .aq-dialog {
67
- width: min(1180px, 100%);
68
- height: min(820px, 100%);
72
+ width: min(1180px, 100%, calc(100vw - 48px));
73
+ height: min(820px, 100%, calc(100vh - 48px));
74
+ height: min(820px, 100%, calc(100dvh - 48px));
75
+ min-width: 0;
69
76
  box-sizing: border-box;
70
77
  padding: 26px 30px;
71
78
  border-radius: 30px;
@@ -86,8 +93,9 @@ html.aq-open { overflow: hidden; }
86
93
  /* The header row. */
87
94
  .aq-head {
88
95
  display: flex;
96
+ flex-wrap: wrap;
89
97
  align-items: center;
90
- gap: 14px;
98
+ gap: 10px 14px;
91
99
  min-width: 0;
92
100
  }
93
101
  .aq-eyebrow {
@@ -104,7 +112,9 @@ html.aq-open { overflow: hidden; }
104
112
  font-weight: 300;
105
113
  line-height: 1.3;
106
114
  color: var(--aq-ink);
115
+ flex: 0 1 auto;
107
116
  min-width: 0;
117
+ overflow-wrap: anywhere;
108
118
  }
109
119
  .aq-title__sub { font-size: 14px; color: var(--aq-ink-dim); }
110
120
  .aq-head__gap { margin-left: auto; }
@@ -143,6 +153,7 @@ html.aq-open { overflow: hidden; }
143
153
  .aq-body {
144
154
  flex-grow: 1;
145
155
  min-height: 0;
156
+ min-width: 0;
146
157
  display: flex;
147
158
  gap: 16px;
148
159
  }
@@ -172,8 +183,10 @@ html.aq-open { overflow: hidden; }
172
183
  color: var(--aq-paper-ink);
173
184
  }
174
185
  .aq-lines {
175
- width: 300px;
186
+ /* 300px, the artboard's, until the page beside it has shrunk; then down to 240. */
187
+ width: clamp(240px, 32%, 300px);
176
188
  flex: none;
189
+ min-width: 0;
177
190
  min-height: 0;
178
191
  overflow-y: auto;
179
192
  display: flex;
@@ -197,15 +210,28 @@ html.aq-open { overflow: hidden; }
197
210
  text-transform: uppercase;
198
211
  color: var(--aq-ink-faint);
199
212
  }
200
- .aq-line__text { font-size: 15px; line-height: 1.5; color: var(--aq-ink); }
213
+ .aq-line__text { font-size: 15px; line-height: 1.5; color: var(--aq-ink); overflow-wrap: anywhere; }
201
214
  .aq-mark { background: var(--aq-mark); color: var(--aq-ink); border-radius: 4px; padding: 0 3px; }
202
- .aq-line__aside { font-size: 13px; line-height: 1.45; color: var(--aq-ink-faint); }
215
+ /* A brand hole ({{productName}}) shown as the name it stands for: a fixed name
216
+ the artist did not write and cannot rewrite, so a quiet chip (task 1004381). */
217
+ .aq-hole {
218
+ display: inline-block;
219
+ padding: 0 6px;
220
+ border-radius: 6px;
221
+ border: 1px solid var(--aq-glass-edge);
222
+ background: var(--aq-pill-on);
223
+ color: inherit;
224
+ line-height: 1.35;
225
+ white-space: nowrap;
226
+ }
227
+ .aq-line__aside { font-size: 13px; line-height: 1.45; color: var(--aq-ink-faint); overflow-wrap: anywhere; }
203
228
  .aq-line--refused .aq-line__aside { color: var(--aq-link); }
204
229
  .aq-lines__none { margin: 0; font-size: 14px; color: var(--aq-ink-faint); }
205
230
 
206
231
  /* The decision row. */
207
232
  .aq-foot {
208
233
  display: flex;
234
+ flex-wrap: wrap;
209
235
  align-items: center;
210
236
  gap: 12px;
211
237
  min-height: 48px;
@@ -267,7 +293,7 @@ html.aq-open { overflow: hidden; }
267
293
  .aq-overlay { padding: 8px; align-items: stretch; --aq-scrim: rgba(4, 5, 9, .88); }
268
294
  html[data-theme] .aq-overlay,
269
295
  html:not([data-theme]) .aq-overlay { --aq-scrim: rgba(4, 5, 9, .88); }
270
- .aq-dialog { height: 100%; padding: 18px 16px; border-radius: 22px; overflow-y: auto; gap: 14px; }
296
+ .aq-dialog { width: min(100%, calc(100vw - 16px)); height: min(100%, calc(100dvh - 16px)); padding: 18px 16px; border-radius: 22px; overflow-y: auto; gap: 14px; }
271
297
  .aq-dialog > * { flex-shrink: 0; }
272
298
  .aq-head { flex-wrap: wrap; row-gap: 10px; }
273
299
  .aq-eyebrow { order: 1; flex: 1 1 auto; }
@@ -28,6 +28,9 @@
28
28
  'use strict';
29
29
 
30
30
  const L = window.OTBApprovalQueueLib;
31
+ // A page's brand holes ({{productName}}) read as the name they stand for, in a
32
+ // chip (brand-holes.js, task 1004381). Escaped text when it is not loaded.
33
+ const H = window.OTBBrandHoles || null;
31
34
 
32
35
  function esc(s) {
33
36
  return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@ -137,14 +140,18 @@
137
140
  return `${moved}<img class="aq-render aq-render--${esc(pick.device)}" src="${esc(pick.url)}" alt="${esc(alt)}">`;
138
141
  }
139
142
 
143
+ function words(text) {
144
+ return H ? H.holesHtml(text, { cls: 'aq-hole' }) : esc(text);
145
+ }
146
+
140
147
  function linesHtml(item) {
141
148
  const rows = (item.lines || []).map((l) => L.lineRow(l, state.view.phase));
142
149
  if (!rows.length) return '<p class="aq-lines__none">This round changed no lines.</p>';
143
150
  return `<ol class="aq-lines__list">${rows.map((r) => `
144
151
  <li class="aq-line${r.refused ? ' aq-line--refused' : ''}">
145
152
  ${r.section ? `<span class="aq-line__section">${esc(r.section)}</span>` : ''}
146
- <span class="aq-line__text">${r.marked ? `<mark class="aq-mark">${esc(r.text)}</mark>` : esc(r.text)}</span>
147
- <span class="aq-line__aside">${esc(r.aside)}</span>
153
+ <span class="aq-line__text">${r.marked ? `<mark class="aq-mark">${words(r.text)}</mark>` : words(r.text)}</span>
154
+ <span class="aq-line__aside">${words(r.aside)}</span>
148
155
  </li>`).join('')}</ol>`;
149
156
  }
150
157
 
@@ -91,7 +91,7 @@
91
91
 
92
92
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
93
93
 
94
- <script src="/builders/shell.js?v=2026-09-28-archive"></script>
94
+ <script src="/builders/shell.js?v=2026-09-29-renderdeploy"></script>
95
95
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
96
96
  <script src="/builders/palette.js" defer></script>
97
97
  <script src="/builders/atlas.js?v=2026-09-02-index" defer></script>
@@ -50,7 +50,7 @@
50
50
 
51
51
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
52
52
  chrome exists before the page script runs. -->
53
- <script src="/builders/shell.js?v=2026-09-28-archive"></script>
53
+ <script src="/builders/shell.js?v=2026-09-29-renderdeploy"></script>
54
54
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
55
55
  <script src="/builders/palette.js" defer></script>
56
56
  <!-- shared frontend toolkit (window.OTB — rank labels, number formats) + the
@@ -60,7 +60,7 @@
60
60
 
61
61
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
62
62
 
63
- <script src="/builders/shell.js?v=2026-09-28-archive"></script>
63
+ <script src="/builders/shell.js?v=2026-09-29-renderdeploy"></script>
64
64
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
65
65
  <script src="/builders/palette.js" defer></script>
66
66
  <script src="/builders/dom-utils.js?v=2026-06-16-board"></script>