@bongos/core 1.19.1081 → 1.20.2

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 (253) hide show
  1. package/.bongos-core.json +528 -193
  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 +32 -0
  5. package/clients/bongos-client/index.cjs +32 -0
  6. package/clients/bongos-client/index.d.ts +51 -6
  7. package/clients/bongos-client/index.mjs +32 -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/0348-the-web-tier-may-look-read-only-at-what-an-owners-render-key-can-see.md +53 -0
  14. package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +106 -0
  15. package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +103 -0
  16. package/docs/adr/README.md +3 -0
  17. package/docs/api/openapi.json +851 -19
  18. package/docs/api-reference.md +33 -9
  19. package/docs/architecture.md +6 -2
  20. package/docs/copy-inventory.md +620 -553
  21. package/docs/copy-registry.json +1467 -820
  22. package/docs/file-map.md +2 -0
  23. package/docs/module-api-changelog.md +8 -0
  24. package/docs/modules-contract.md +1 -0
  25. package/docs/page-inventory.json +38 -4
  26. package/docs/page-readings.json +1666 -1558
  27. package/migrations/core_260_goals_working_area.sql +63 -0
  28. package/migrations/core_261_grade_attempts_verdicts.sql +24 -0
  29. package/migrations/core_262_drop_builder_box_blocked.sql +33 -0
  30. package/modules/agents/lib/validate.js +43 -0
  31. package/modules/autonomy/gauge.js +38 -2
  32. package/modules/grading/grader-subagent.js +37 -2
  33. package/modules/grading/grader-workers/reader.js +65 -5
  34. package/modules/hall-ui/public/approval-queue.css +33 -7
  35. package/modules/hall-ui/public/approval-queue.js +9 -2
  36. package/modules/hall-ui/public/atlas.html +1 -1
  37. package/modules/hall-ui/public/blockers.html +1 -1
  38. package/modules/hall-ui/public/board-room.html +1 -1
  39. package/modules/hall-ui/public/brand-holes.js +121 -0
  40. package/modules/hall-ui/public/collab.html +1 -1
  41. package/modules/hall-ui/public/collab.js +1 -1
  42. package/modules/hall-ui/public/copy-desk.html +1 -1
  43. package/modules/hall-ui/public/deploy.html +6 -1
  44. package/modules/hall-ui/public/deploy.js +22 -8
  45. package/modules/hall-ui/public/diagrams.html +1 -1
  46. package/modules/hall-ui/public/dom-utils.js +20 -0
  47. package/modules/hall-ui/public/drachmae.html +1 -1
  48. package/modules/hall-ui/public/fleet.html +1 -1
  49. package/modules/hall-ui/public/gate.html +1 -1
  50. package/modules/hall-ui/public/goal-inbox.js +1 -1
  51. package/modules/hall-ui/public/goals.html +1 -1
  52. package/modules/hall-ui/public/government.html +1 -1
  53. package/modules/hall-ui/public/idea.html +1 -1
  54. package/modules/hall-ui/public/idea.js +28 -0
  55. package/modules/hall-ui/public/ideas.html +1 -1
  56. package/modules/hall-ui/public/ideas.js +19 -3
  57. package/modules/hall-ui/public/index.html +5 -1
  58. package/modules/hall-ui/public/modules.html +1 -1
  59. package/modules/hall-ui/public/primer.html +1 -1
  60. package/modules/hall-ui/public/profile-nudge.js +21 -4
  61. package/modules/hall-ui/public/profile.html +1 -1
  62. package/modules/hall-ui/public/profile.js +1 -1
  63. package/modules/hall-ui/public/project-settings.html +1 -1
  64. package/modules/hall-ui/public/ranks.html +1 -1
  65. package/modules/hall-ui/public/roadmap.html +1 -1
  66. package/modules/hall-ui/public/roster.html +1 -1
  67. package/modules/hall-ui/public/sessions.html +1 -1
  68. package/modules/hall-ui/public/settings.html +19 -30
  69. package/modules/hall-ui/public/settings.js +24 -177
  70. package/modules/hall-ui/public/settings.states.json +1 -1
  71. package/modules/hall-ui/public/shell.js +4 -2
  72. package/modules/hall-ui/public/studio.css +14 -8
  73. package/modules/hall-ui/public/studio.html +5 -4
  74. package/modules/hall-ui/public/task.html +1 -1
  75. package/modules/hall-ui/public/thinking.css +6 -3
  76. package/modules/hall-ui/public/thinking.html +1 -1
  77. package/modules/hall-ui/public/tweak-editor-lib.js +82 -1
  78. package/modules/hall-ui/public/tweak-editor.css +142 -11
  79. package/modules/hall-ui/public/tweak-editor.html +25 -9
  80. package/modules/hall-ui/public/tweak-editor.js +243 -10
  81. package/modules/hall-ui/public/watch.html +1 -1
  82. package/modules/hall-ui/public/work.html +1 -1
  83. package/modules/hall-ui/records/approval-queue.md +6 -0
  84. package/modules/hall-ui/records/tweak-editor.md +6 -0
  85. package/modules/ideas/ratify.js +36 -8
  86. package/modules/ideas/routes/ratify-goal.js +6 -0
  87. package/modules/lifecycle/db-analytics.js +79 -10
  88. package/modules/lifecycle/db-claims.js +48 -1
  89. package/modules/lifecycle/db-deps-criteria.js +94 -40
  90. package/modules/lifecycle/db-goals.js +4 -1
  91. package/modules/lifecycle/db-grade.js +9 -1
  92. package/modules/lifecycle/db-relevance-flags.js +183 -0
  93. package/modules/lifecycle/db-versions.js +4 -3
  94. package/modules/lifecycle/db.js +19 -0
  95. package/modules/lifecycle/done-when.js +5 -3
  96. package/modules/lifecycle/est-advisory.js +31 -4
  97. package/modules/lifecycle/goal-task-relevance-advisory.js +153 -0
  98. package/modules/lifecycle/goal-task-relevance-judge.js +226 -0
  99. package/modules/lifecycle/migrations/lifecycle_014_goal_task_relevance_flags.sql +82 -0
  100. package/modules/lifecycle/module.json +2 -1
  101. package/modules/lifecycle/routes/claims.js +8 -1
  102. package/modules/lifecycle/routes/goal-task-relevance.js +72 -0
  103. package/modules/lifecycle/routes/goals.js +1 -1
  104. package/modules/lifecycle/routes/tasks.js +27 -24
  105. package/modules/lifecycle/routes/visuals.js +9 -0
  106. package/modules/lifecycle/ship-preflight.js +7 -11
  107. package/modules/lifecycle/task-visuals.js +109 -5
  108. package/modules/npm-release/module.json +2 -1
  109. package/modules/npm-release/preview/commands.js +81 -0
  110. package/modules/npm-release/preview/divert.js +95 -0
  111. package/modules/npm-release/preview/env.js +78 -0
  112. package/modules/npm-release/preview/proxy.js +107 -0
  113. package/modules/npm-release/preview/runtime.js +83 -0
  114. package/modules/npm-release/preview/supervisor.js +175 -0
  115. package/modules/npm-release/public/work.js +65 -1
  116. package/modules/npm-release/routes/preview.js +133 -0
  117. package/modules/provisioning/app-status.js +83 -0
  118. package/modules/provisioning/module.json +2 -1
  119. package/modules/provisioning/pollers/app-liveness.js +110 -0
  120. package/modules/provisioning/render-lookup.js +68 -0
  121. package/modules/provisioning/render-standup.js +44 -1
  122. package/modules/provisioning/routes/render-standup.js +81 -6
  123. package/modules/provisioning/starter-bundles.js +4 -1
  124. package/modules/public-landing/public/account-private.states.json +14 -0
  125. package/modules/public-landing/public/account.html +513 -0
  126. package/modules/public-landing/public/account.probes.json +37 -0
  127. package/modules/public-landing/public/account.states.json +16 -0
  128. package/modules/public-landing/public/index.html +5 -2
  129. package/modules/public-landing/public/projects.html +661 -18
  130. package/modules/public-landing/public/projects.states.json +8 -1
  131. package/modules/render-deploy/deploys.js +203 -0
  132. package/modules/render-deploy/migrations/render_deploy_001_app.sql +22 -0
  133. package/modules/render-deploy/module.json +25 -0
  134. package/modules/render-deploy/public/deploy.css +10 -0
  135. package/modules/render-deploy/public/deploy.js +249 -0
  136. package/modules/render-deploy/render.js +56 -0
  137. package/modules/render-deploy/routes/act.js +88 -0
  138. package/modules/render-deploy/routes/app.js +65 -0
  139. package/modules/render-deploy/routes/history.js +38 -0
  140. package/modules/specialities/db.js +27 -8
  141. package/modules/specialities/migrations/specialities_003_skills.sql +46 -0
  142. package/modules/specialities/routes/specialities.js +51 -3
  143. package/modules/specialities/skills.js +94 -0
  144. package/modules/specialities/specialities.js +56 -9
  145. package/modules/ui-design/kit/fixtures/me-cross-project-private.json +12 -0
  146. package/modules/ui-design/kit/fixtures/me__cross-project.json +15 -0
  147. package/modules/ui-design/kit/fixtures/provisioning-instance-render.json +39 -0
  148. package/modules/ui-design/kit/lib.js +3 -1
  149. package/modules/ui-design/kit/serve.js +96 -0
  150. package/package-lock.json +2 -2
  151. package/package.json +1 -1
  152. package/release-notes.json +169 -0
  153. package/scripts/gds/agents-sync.js +13 -3
  154. package/scripts/gds/autobongos-run.js +80 -2
  155. package/scripts/gds/autobongos-service.cmd +12 -0
  156. package/scripts/gds/copy-apply.js +14 -0
  157. package/scripts/gds/dev-box-guard.js +3 -2
  158. package/scripts/gds/fitness.js +9 -0
  159. package/scripts/gds/grade-correlation-audit.js +42 -12
  160. package/scripts/gds/grade-replay.js +1 -1
  161. package/scripts/gds/provision-core-upgrade.js +38 -7
  162. package/scripts/gds/provision-pin-key.js +176 -0
  163. package/scripts/gds/provision-render.js +62 -31
  164. package/scripts/gds/provision-teardown.js +7 -2
  165. package/scripts/gds/provision-units.js +31 -0
  166. package/scripts/gds/provision.js +10 -10
  167. package/scripts/gds/publish-manifest.js +1 -0
  168. package/scripts/gds/render-api.js +19 -0
  169. package/scripts/gds/render-payload.js +39 -65
  170. package/scripts/gds/run-unit-tests.js +10 -0
  171. package/scripts/gds/ship-finish.js +19 -20
  172. package/scripts/gds/smoke-dependencies.sh +37 -8
  173. package/scripts/gds/status.js +12 -4
  174. package/scripts/gds/upgrade.js +2 -2
  175. package/scripts/public-mirror-export.js +7 -1
  176. package/src/bongos/module-scope-map.js +13 -0
  177. package/src/bongos/serve-internal.js +3 -1
  178. package/src/module-api.js +10 -1
  179. package/src/platform-server.js +39 -0
  180. package/tests/account_privacy_flags.mjs +10 -6
  181. package/tests/agents_authoring.mjs +15 -1
  182. package/tests/agents_sync.mjs +106 -5
  183. package/tests/agents_validate.mjs +45 -0
  184. package/tests/api_path_404.mjs +6 -0
  185. package/tests/autobongos_cadence.mjs +7 -0
  186. package/tests/autobongos_loop.mjs +136 -1
  187. package/tests/autonomy_gauge.mjs +62 -0
  188. package/tests/blocker_hall_live_proof.mjs +275 -0
  189. package/tests/claim_gate_ci_unblock.mjs +116 -0
  190. package/tests/claim_gate_rebase.mjs +15 -3
  191. package/tests/collab_page.mjs +10 -0
  192. package/tests/conductor_main_e2e.mjs +97 -0
  193. package/tests/core_262_drop_box_blocked_db.mjs +143 -0
  194. package/tests/core_upgrade_runner.mjs +1 -0
  195. package/tests/dependency_writes_atomic.mjs +191 -0
  196. package/tests/deploy_page_projects.mjs +2 -1
  197. package/tests/effective_visibility_predicate.mjs +15 -0
  198. package/tests/est_advisory.mjs +27 -0
  199. package/tests/goal_map_page.mjs +26 -0
  200. package/tests/goal_task_relevance.mjs +518 -0
  201. package/tests/goal_working_area.mjs +146 -0
  202. package/tests/grade_attempts.mjs +61 -0
  203. package/tests/grade_attribution.mjs +23 -0
  204. package/tests/grade_correlation_audit.mjs +54 -1
  205. package/tests/grader_reader_lens.mjs +55 -2
  206. package/tests/grader_root_outage.mjs +6 -1
  207. package/tests/grader_subagent_tools_arg.mjs +102 -0
  208. package/tests/hall_approval_queue.mjs +54 -2
  209. package/tests/hall_settings_world.mjs +3 -1
  210. package/tests/hall_tweak_editor.mjs +316 -11
  211. package/tests/hub_account_page.mjs +394 -0
  212. package/tests/idea_detail_page.mjs +64 -0
  213. package/tests/idea_goal_ratification.mjs +64 -0
  214. package/tests/idea_spark_hall.mjs +60 -9
  215. package/tests/ideator_full_idea_shapes_space_proof.mjs +1 -1
  216. package/tests/landing_page.mjs +6 -3
  217. package/tests/module-scope-map.mjs +31 -8
  218. package/tests/module_api.mjs +1 -0
  219. package/tests/module_loader.mjs +1 -1
  220. package/tests/nav_permission_atoms.mjs +1 -1
  221. package/tests/no_phantom_mirror_workflow.mjs +24 -0
  222. package/tests/npm_release_preview_commands.mjs +89 -0
  223. package/tests/npm_release_preview_divert.mjs +164 -0
  224. package/tests/npm_release_preview_env.mjs +100 -0
  225. package/tests/npm_release_preview_proxy.mjs +162 -0
  226. package/tests/npm_release_preview_routes.mjs +208 -0
  227. package/tests/npm_release_preview_supervisor.mjs +203 -0
  228. package/tests/pin_write_key.mjs +383 -0
  229. package/tests/planning_session_skill.mjs +27 -0
  230. package/tests/platform_boot.mjs +71 -0
  231. package/tests/profile_nudge_links.mjs +86 -0
  232. package/tests/profile_ui_cross_project.mjs +1 -1
  233. package/tests/projects_hub.mjs +2 -2
  234. package/tests/projects_hub_app_status.mjs +249 -0
  235. package/tests/projects_hub_app_step.mjs +282 -52
  236. package/tests/projects_hub_render_connect.mjs +295 -0
  237. package/tests/provision_render.mjs +121 -28
  238. package/tests/provision_settings_apply.mjs +62 -0
  239. package/tests/provisioning_app_status.mjs +204 -0
  240. package/tests/provisioning_render_route.mjs +126 -6
  241. package/tests/render_check.mjs +2 -1
  242. package/tests/render_deploy.mjs +495 -0
  243. package/tests/rot_inbox.mjs +8 -0
  244. package/tests/ship_preflight.mjs +61 -8
  245. package/tests/smoke_dependencies_witness.mjs +46 -0
  246. package/tests/speciality_skills.mjs +214 -0
  247. package/tests/studio_room_height.mjs +70 -0
  248. package/tests/task_detail_includes.mjs +10 -0
  249. package/tests/task_visuals_instance_root.mjs +150 -0
  250. package/tests/tweak_batch_apply.mjs +21 -0
  251. package/tests/ui_design_kit.mjs +36 -4
  252. package/tests/upgrade.mjs +1 -1
  253. package/tests/upgrade_persist_pin.mjs +24 -0
@@ -113,6 +113,67 @@ await test('gradeAttemptStats clamps the window (bad input → 7, oversize → 9
113
113
  assert.deepEqual(seen, [7, 7, 90, 90], 'both queries share the clamped window');
114
114
  });
115
115
 
116
+ // ---- task 1004292: per-worker verdicts ride the attempt row ----
117
+
118
+ const WORKERS = [
119
+ { kind: 'narc', verdict: 'pass', error: null, issues: [] },
120
+ { kind: 'hacker', verdict: 'concerns', error: null, issues: [{ severity: 'major', summary: 'SECRET PROSE', file: 'a.js' }, { severity: 'minor', summary: 'more prose' }] },
121
+ { kind: 'quality', verdict: 'fail', error: 'SUBAGENT_TIMEOUT', issues: [], stderr_tail: 'oops' },
122
+ ];
123
+
124
+ await test('workerVerdicts keeps kind, verdict, error and severities and NOTHING else', () => {
125
+ const v = db.workerVerdicts(WORKERS);
126
+ assert.deepEqual(v[1], { kind: 'hacker', verdict: 'concerns', error: null, severities: ['major', 'minor'] });
127
+ assert.deepEqual(v[2], { kind: 'quality', verdict: 'fail', error: 'SUBAGENT_TIMEOUT', severities: [] });
128
+ const text = JSON.stringify(v);
129
+ assert.ok(!/PROSE|a\.js|oops|summary|stderr/.test(text), 'no prose, files or stderr');
130
+ assert.equal(db.workerVerdicts(undefined), null);
131
+ assert.equal(db.workerVerdicts([]), null);
132
+ });
133
+
134
+ await test('workerVerdicts caps the workers and severities it keeps', () => {
135
+ const many = Array.from({ length: db.VERDICT_LIMITS.workers + 5 }, (_, i) => ({ kind: `w${i}`, verdict: 'pass', issues: [] }));
136
+ assert.equal(db.workerVerdicts(many).length, db.VERDICT_LIMITS.workers);
137
+ const sev = [{ kind: 'x', verdict: 'fail', issues: Array.from({ length: db.VERDICT_LIMITS.severities + 9 }, () => ({ severity: 'minor' })) }];
138
+ assert.equal(db.workerVerdicts(sev)[0].severities.length, db.VERDICT_LIMITS.severities);
139
+ });
140
+
141
+ await test('logGradeAttempt writes worker_verdicts as one jsonb param', async () => {
142
+ const calls = [];
143
+ const r = await db.logGradeAttempt({ taskId: 9, builderId: 1, passed: false, score: 5, workers: WORKERS },
144
+ (text, params) => { calls.push({ text, params }); return Promise.resolve({ rows: [] }); });
145
+ assert.equal(r, true);
146
+ assert.equal(calls.length, 1);
147
+ assert.ok(/worker_verdicts/.test(calls[0].text));
148
+ assert.equal(calls[0].params.length, 5);
149
+ assert.equal(JSON.parse(calls[0].params[4])[1].kind, 'hacker');
150
+ });
151
+
152
+ await test('a failing worker_verdicts write never costs the attempt row (column not migrated)', async () => {
153
+ const calls = [];
154
+ const r = await db.logGradeAttempt({ taskId: 9, builderId: 1, passed: true, score: 8, workers: WORKERS },
155
+ (text) => {
156
+ calls.push(text);
157
+ if (/worker_verdicts/.test(text)) return Promise.reject(new Error('column "worker_verdicts" does not exist'));
158
+ return Promise.resolve({ rows: [] });
159
+ });
160
+ assert.equal(r, true, 'the plain row still lands');
161
+ assert.equal(calls.length, 2);
162
+ assert.ok(!/worker_verdicts/.test(calls[1]));
163
+ const both = await db.logGradeAttempt({ taskId: 9, passed: true, score: 8, workers: WORKERS }, () => { throw new Error('db down'); });
164
+ assert.equal(both, false, 'and a total failure still never throws');
165
+ });
166
+
167
+ await test('gradeAttemptVerdicts reads only rounds that kept verdicts, and never throws', async () => {
168
+ let sql;
169
+ const rows = await db.gradeAttemptVerdicts('12', (text, params) => { sql = { text, params }; return Promise.resolve({ rows: [{ passed: false, workers: [] }] }); });
170
+ assert.equal(rows.length, 1);
171
+ assert.ok(/worker_verdicts IS NOT NULL/.test(sql.text) && /ORDER BY attempted_at ASC/.test(sql.text));
172
+ assert.ok(sql.text.includes('LIMIT $2'), 'bounded to the most recent rounds');
173
+ assert.deepEqual(sql.params, [12, db.VERDICT_LIMITS.rounds]);
174
+ assert.deepEqual(await db.gradeAttemptVerdicts(12, () => Promise.reject(new Error('no column'))), []);
175
+ });
176
+
116
177
  console.log('');
117
178
  console.log(`Grade-attempts (6D.6) tests: ${passed} passed, ${failed} failed`);
118
179
  if (failed > 0) process.exit(1);
@@ -114,4 +114,27 @@ await test('grade: NO claim on the task → falls back to the caller (never refu
114
114
  assert.equal(out.creditsAwarded, 0, 'still no per-task credit at grader-confirm (lands at ship)');
115
115
  });
116
116
 
117
+ // task 1004292: applyGrade hands the panel's per-worker verdicts to the attempt
118
+ // log (best-effort, post-commit), verdicts only — no prose.
119
+ await test('grade: the attempt row carries the worker verdicts and no prose', async () => {
120
+ const pool = fakePool({ claimHolderId: WORKER, task: TASK });
121
+ const seen = [];
122
+ const attemptQuery = (text, params) => { seen.push({ text, params }); return Promise.resolve({ rows: [] }); };
123
+ const grade = { ...passingGrade, signals: { workers: [
124
+ { kind: 'hacker', verdict: 'concerns', error: null, issues: [{ severity: 'major', summary: 'PROSE HERE' }] },
125
+ ] } };
126
+ await applyGrade({ taskId: TASK.id, builderId: ARCHON, gradeResult: grade }, { pool, attemptQuery });
127
+ assert.equal(seen.length, 1);
128
+ assert.match(seen[0].text, /INSERT INTO grade_attempts .*worker_verdicts/);
129
+ assert.deepEqual(JSON.parse(seen[0].params[4]), [{ kind: 'hacker', verdict: 'concerns', error: null, severities: ['major'] }]);
130
+ assert.ok(!JSON.stringify(seen[0].params).includes('PROSE'));
131
+ });
132
+
133
+ await test('grade: a failing verdicts write never fails the grade', async () => {
134
+ const pool = fakePool({ claimHolderId: WORKER, task: TASK });
135
+ const grade = { ...passingGrade, signals: { workers: [{ kind: 'hacker', verdict: 'pass', issues: [] }] } };
136
+ const out = await applyGrade({ taskId: TASK.id, builderId: ARCHON, gradeResult: grade }, { pool, attemptQuery: () => Promise.reject(new Error('boom')) });
137
+ assert.equal(out.taskStatus, 'confirmed');
138
+ });
139
+
117
140
  summary();
@@ -148,7 +148,7 @@ test('the CLI reports a saved dataset — refusals by name, and --json — with
148
148
  fs.writeFileSync(file, JSON.stringify({ grades: [row(1), row(2, { quality: 'fail' })], overrides: [] }));
149
149
  try {
150
150
  const text = execFileSync(process.execPath, ['scripts/gds/grade-correlation-audit.js', '--file', file], { cwd: ROOT, encoding: 'utf8' });
151
- assert.match(text, /2 recorded panel grade\(s\), minimum n = 30/);
151
+ assert.match(text, /2 recorded panel round\(s\) over 2 task\(s\), minimum n = 30/);
152
152
  assert.match(text, /narc × quality\s+REFUSED — n=2 is below the minimum of 30/);
153
153
  const json = JSON.parse(execFileSync(process.execPath, ['scripts/gds/grade-correlation-audit.js', '--file', file, '--json'], { cwd: ROOT, encoding: 'utf8' }));
154
154
  assert.equal(json.panels, 2);
@@ -157,3 +157,56 @@ test('the CLI reports a saved dataset — refusals by name, and --json — with
157
157
  fs.rmSync(dir, { recursive: true, force: true });
158
158
  }
159
159
  });
160
+
161
+ // ---- task 1004292: earlier rounds ----
162
+
163
+ // A stored attempt: kind, verdict, error, severities. No prose.
164
+ const AW = (kind, verdict, severities = [], error = null) => ({ kind, verdict, error, severities });
165
+ const attempt = (o) => ({ attempted_at: '2026-09-29T00:00:00Z', passed: false, workers: [AW('narc', o.narc || 'pass'), AW('quality', o.quality || 'pass'), AW('hacker', o.hacker || 'pass', o.hs), AW('efficiency', o.efficiency || 'pass')] });
166
+ const gradeOf = (attempts) => ({ passed: true, signals: { workers: [W('narc', 'pass'), W('quality', 'pass'), W('hacker', 'pass'), W('efficiency', 'pass')] }, attempts });
167
+
168
+ test('a stored attempt votes from its severities alone', () => {
169
+ assert.equal(A.voteOf(AW('hacker', 'concerns', ['minor'])), 1);
170
+ assert.equal(A.voteOf(AW('hacker', 'pass')), 0);
171
+ assert.equal(A.voteOf(AW('hacker', 'fail', ['blocker'], 'parse_error')), 1, 'errored but substantive is still an objection');
172
+ assert.equal(A.voteOf(AW('hacker', 'fail', [], 'SUBAGENT_TIMEOUT')), null, 'errored with no finding casts none');
173
+ });
174
+
175
+ test('toAuditRow keeps every round that kept its verdicts; a task with none falls back to the final grade', () => {
176
+ const r = A.toAuditRow({ task_id: 5, task_status: 'shipped', grade: gradeOf([attempt({ hacker: 'concerns', hs: ['major'] }), attempt({})]) });
177
+ assert.equal(r.rounds.length, 2);
178
+ assert.equal(r.rounds[0].find((w) => w.kind === 'hacker').verdict, 'concerns');
179
+ assert.equal(JSON.stringify(r.rounds).includes('summary'), false, 'no prose travels with a round');
180
+ const legacy = A.toAuditRow({ task_id: 6, task_status: 'shipped', grade: gradeOf([]) });
181
+ assert.equal(legacy.rounds, undefined);
182
+ assert.equal(A.panelsOf(legacy).length, 1);
183
+ });
184
+
185
+ test('the audit counts earlier rounds: an objection fixed before the final grade is still a vote', () => {
186
+ // 40 tasks: round 1 the hacker and efficiency both object on half, round 2 (final) all clear.
187
+ const grades = Array.from({ length: 40 }, (_, i) => A.toAuditRow({
188
+ task_id: i, task_status: 'shipped',
189
+ grade: gradeOf([attempt({ hacker: i < 20 ? 'concerns' : 'pass', hs: ['major'], efficiency: i < 20 ? 'concerns' : 'pass' }), attempt({})]),
190
+ }));
191
+ const withRounds = A.audit({ grades, overrides: [] }, { minN: 30 });
192
+ assert.equal(withRounds.panels, 80);
193
+ assert.equal(withRounds.tasks, 40);
194
+ const he = withRounds.pairwise.find((p) => p.a === 'hacker' && p.b === 'efficiency');
195
+ assert.equal(he.refused, undefined, 'computable once earlier rounds are visible');
196
+ assert.equal(he.table.both_object, 20);
197
+ // The same tasks seen through their final grade alone: the objections are gone.
198
+ const finalOnly = A.audit({ grades: grades.map(({ rounds, ...g }) => g), overrides: [] }, { minN: 30 });
199
+ assert.notEqual(finalOnly.pairwise.find((p) => p.a === 'hacker' && p.b === 'efficiency').refused, undefined);
200
+ });
201
+
202
+ test('owner labels compare against the FINAL round only', () => {
203
+ const grades = Array.from({ length: 40 }, (_, i) => A.toAuditRow({
204
+ task_id: i, task_status: 'shipped',
205
+ // round 1 objects (quality), final clears; the owner accepted the final work.
206
+ grade: { ...gradeOf([attempt({ quality: 'concerns' }), attempt({})]), passed: false },
207
+ }));
208
+ const overrides = grades.map((g) => ({ task_id: g.task_id, status: 'approved' }));
209
+ const rep = A.audit({ grades, overrides }, { minN: 30 });
210
+ const q = rep.vs_owner.find((w) => w.kind === 'quality');
211
+ assert.equal(q.n, 40, 'one labelled observation per task, not per round');
212
+ });
@@ -200,13 +200,66 @@ test('the prompt sends the worker to the written guide, and forbids an uncited f
200
200
  assert.match(p, /cites no rule id is not a finding/i, "the guide's own rule travels with it");
201
201
  // The hard/judgement split is what stops an advisory lens recording taste as
202
202
  // fact, so it is pinned rather than left to the prompt's good intentions.
203
- assert.match(p, /V1-V5/, 'hard rules may be asserted');
204
- assert.match(p, /V6-V11/, 'judgement rules may only be flagged');
203
+ // Read from the guide since task 1004075, so the pin is on what the guide
204
+ // says, not on a literal range the guide may outgrow.
205
+ const ids = reader.ruleIdsText(reader.voiceRuleClasses(fs.readFileSync(reader.VOICE_GUIDE, 'utf8')));
206
+ assert.ok(p.includes(`Hard rules (${ids.hard})`), 'hard rules may be asserted: ' + ids.hard);
207
+ assert.ok(p.includes(`Judgement rules (${ids.judgement})`), 'judgement rules may only be flagged: ' + ids.judgement);
205
208
  assert.match(p, /ADVISORY/, 'the worker is told it cannot block');
206
209
  assert.ok(!/blocker/.test(p.split('## Return ONLY JSON')[1] || ''),
207
210
  'the JSON schema offers no blocker severity');
208
211
  });
209
212
 
213
+ // task 1004075: the guide grows by adding rules (its own Part D), so the lens
214
+ // must follow the file rather than a range typed into the prompt.
215
+ test('every rule id in docs/voice.md is a hard rule or a judgement rule, none left over', () => {
216
+ const text = fs.readFileSync(reader.VOICE_GUIDE, 'utf8');
217
+ const c = reader.voiceRuleClasses(text);
218
+ const all = [...text.matchAll(/^## (V\d+)\b/gm)].map((m) => m[1]);
219
+ assert.ok(all.length >= 11, 'the guide still carries its rules, saw ' + all.length);
220
+ assert.deepEqual(c.unclassified, [], 'a rule under neither Part A nor Part B reaches the lens unclassified');
221
+ assert.deepEqual([...c.hard, ...c.judgement].sort(), [...all].sort(), 'every rule is classified exactly once');
222
+ assert.ok(c.hard.length > 0 && c.judgement.length > 0, 'both classes are populated');
223
+ });
224
+
225
+ test('a twelfth rule, or a split rule, reaches the prompt with no change to reader.js', () => {
226
+ const text = fs.readFileSync(reader.VOICE_GUIDE, 'utf8');
227
+ const grown = text.replace(/\n# Part C/, '\n## V12 — A new judgement rule\n\nprose\n\n# Part C');
228
+ assert.notEqual(grown, text, 'fixture: Part C is still where the judgement rules end');
229
+ const c = reader.voiceRuleClasses(grown);
230
+ assert.ok(c.judgement.includes('V12'), 'the new rule is a judgement rule');
231
+ assert.equal(reader.ruleIdsText(c).judgement.endsWith('V12'), true, reader.ruleIdsText(c).judgement);
232
+ // splitting V4 renumbers: the ranges must follow whatever ids the file has
233
+ assert.equal(reader.formatRuleIds(['V1', 'V2', 'V3', 'V4', 'V5', 'V6']), 'V1-V6');
234
+ assert.equal(reader.formatRuleIds(['V7', 'V8', 'V10']), 'V7-V8, V10');
235
+ });
236
+
237
+ test('the PROMPT itself follows the guide: a grown guide reaches the header with no code change', () => {
238
+ // The unit checks above prove the parser; this proves the prompt uses it. A
239
+ // literal range typed back into the header would pass every other case today,
240
+ // because today's literal happens to match the guide. So load the worker
241
+ // against a guide with a V12 and read the header it builds.
242
+ const real = fs.readFileSync(reader.VOICE_GUIDE, 'utf8');
243
+ const grown = real.replace(/\n# Part C/, '\n## V12 — A new judgement rule\n\nprose\n\n# Part C');
244
+ const origRead = fs.readFileSync;
245
+ const key = require.resolve('../modules/grading/grader-workers/reader.js');
246
+ fs.readFileSync = function (p, ...rest) {
247
+ return String(p) === reader.VOICE_GUIDE ? grown : origRead.call(this, p, ...rest);
248
+ };
249
+ delete require.cache[key];
250
+ let fresh;
251
+ try { fresh = require(key); } finally { fs.readFileSync = origRead; delete require.cache[key]; require(key); }
252
+ assert.ok(fresh.PROMPT_HEADER.includes('Judgement rules (V6-V12)'), 'the header carries the grown judgement range');
253
+ assert.ok(fresh.PROMPT_HEADER.includes('Hard rules (V1-V5)'));
254
+ });
255
+
256
+ test('an unreadable guide makes the prompt name the Parts, never a stale number', () => {
257
+ const ids = reader.ruleIdsText(null);
258
+ assert.match(ids.hard, /Part A/);
259
+ assert.match(ids.judgement, /Part B/);
260
+ assert.ok(!/V\d/.test(ids.hard + ids.judgement));
261
+ });
262
+
210
263
  test('docs/voice.md exists — the lens has a standard to judge against', () => {
211
264
  // The hard dependency (task 1003976). A copy lens with no written standard
212
265
  // records taste and calls it a finding.
@@ -105,5 +105,10 @@ test('the worker spawn picks a permission mode the CLI accepts as root', () => {
105
105
  assert.match(src, /'--permission-mode', permissionMode/);
106
106
  assert.doesNotMatch(src, /'--permission-mode', 'bypassPermissions'/);
107
107
  // The tool list is what makes acceptEdits cost nothing: there are no edits.
108
- assert.match(src, /'--tools', 'Read'/);
108
+ // task 1001691 gave every caller an opt-out (`opts.tools`, for the ADR 0101
109
+ // judge's zero-tools request), so the literal moved into a default rather
110
+ // than a hardcoded --tools argument — every OTHER caller still resolves to
111
+ // exactly 'Read', which is the property this test cares about.
112
+ assert.match(src, /opts\.tools \?\? 'Read'/);
113
+ assert.match(src, /'--tools', tools/);
109
114
  });
@@ -0,0 +1,102 @@
1
+ // tests/grader_subagent_tools_arg.mjs — toolsArgForSpawn, the win32 empty-arg
2
+ // fix behind runClaudeCli's `opts.tools` (task 1001691, round-4 hacker finding).
3
+ //
4
+ // THE BUG THIS PINS. runClaudeCli spawns with `shell: true` on win32 (the
5
+ // claude.cmd shim needs it). Node's shell path on win32 joins argv into ONE
6
+ // command-line string before handing it to cmd.exe, and an empty-string
7
+ // element is silently DROPPED in that join — verified with a real spawn
8
+ // during development (`node -e` reading back its own argv via a stub
9
+ // script): `['--tools', '', '--strict-mcp-config']` arrived at the child as
10
+ // `['--tools', '--strict-mcp-config']`, so `--tools` silently swallowed
11
+ // `--strict-mcp-config` as its value — the opposite of "no tools", and it
12
+ // would have reopened the exact MCP-server exposure task 1000380 closed.
13
+ // `'""'` (two literal quote characters) is the standard Windows workaround:
14
+ // cmd.exe's own argv parsing turns that back into a genuine empty string on
15
+ // the far side — also verified with a real spawn before landing this file.
16
+ //
17
+ // Run: node tests/grader_subagent_tools_arg.mjs
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import { createRequire } from 'node:module';
21
+ import fs from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import { makeRunner } from './helpers.mjs';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const subagent = require('../modules/grading/grader-subagent.js');
28
+ const { toolsArgForSpawn } = subagent;
29
+
30
+ const { test, summary } = makeRunner();
31
+
32
+ await test('non-empty tools values pass through unchanged, on either platform', () => {
33
+ assert.equal(toolsArgForSpawn('Read', { shell: true }), 'Read');
34
+ assert.equal(toolsArgForSpawn('Read', { shell: false }), 'Read');
35
+ assert.equal(toolsArgForSpawn('Read,Write', { shell: true }), 'Read,Write');
36
+ });
37
+
38
+ await test('an empty request on a NON-shell spawn (POSIX) stays a plain empty string', () => {
39
+ assert.equal(toolsArgForSpawn('', { shell: false }), '', 'execve() gets the args array directly — no shell to interpret quoting');
40
+ });
41
+
42
+ await test('an empty request on a SHELL spawn (win32) is rewritten to the quoted-empty token', () => {
43
+ assert.equal(toolsArgForSpawn('', { shell: true }), '""', 'a bare empty string would be dropped by the shell join, shifting every later arg');
44
+ });
45
+
46
+ await test('the CLI-argv reconstruction: a bare "" survives Node\'s shell-join where a plain empty string does not', () => {
47
+ // The claim toolsArgForSpawn exists to make true, stated as a source fact
48
+ // (verified empirically during development with a real spawn — see this
49
+ // file's header) rather than re-run as a live child process on every CI
50
+ // machine, which would make this suite depend on a shell being present.
51
+ const rewritten = toolsArgForSpawn('', { shell: true });
52
+ assert.equal(rewritten.length, 2, 'exactly the two quote characters, nothing else');
53
+ assert.equal(rewritten, '"'.repeat(2));
54
+ });
55
+
56
+ // ---- REAL end-to-end: a genuine spawn through runSubagent -> runClaudeCli,
57
+ // against a stub `claude` binary, on THIS host's real platform. The
58
+ // grade_model_provider.mjs precedent stubs a shebang script and SKIPS on
59
+ // win32 ("the fake CLI is a shebang script") — a shebang has no meaning to
60
+ // cmd.exe. This stub is a `.cmd` file instead, which win32 spawns natively,
61
+ // so this test runs WHERE THE BUG LIVED: on win32, through shell: true,
62
+ // through the actual argv this module hands to cmd.exe — not a mock of
63
+ // child_process, and not only the pure function above. ----
64
+
65
+ await test('end-to-end: --tools "" arrives as a genuine empty string, and --strict-mcp-config is never swallowed as its value', async () => {
66
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'grader-tools-arg-'));
67
+ try {
68
+ let bin;
69
+ if (process.platform === 'win32') {
70
+ bin = path.join(dir, 'claude.cmd');
71
+ const echoJs = path.join(dir, 'echo.js');
72
+ fs.writeFileSync(echoJs,
73
+ "process.stdin.resume();\nprocess.stdin.on('end', () => process.stdout.write(JSON.stringify({ type: 'result', result: JSON.stringify(process.argv.slice(2)), total_cost_usd: 0 })));\n");
74
+ fs.writeFileSync(bin, `@echo off\r\nnode "${echoJs}" %*\r\n`);
75
+ } else {
76
+ bin = path.join(dir, 'claude');
77
+ fs.writeFileSync(bin,
78
+ '#!/usr/bin/env node\nprocess.stdin.resume(); process.stdin.on("end", () => process.stdout.write(JSON.stringify({ type: "result", result: JSON.stringify(process.argv.slice(2)), total_cost_usd: 0 })));\n');
79
+ fs.chmodSync(bin, 0o755);
80
+ }
81
+ // Self-quote `bin` on win32 (round-6 quality finding): shell:true joins
82
+ // [bin, ...args] with spaces before handing the line to cmd.exe, so an
83
+ // unquoted path containing a space (a real risk from a bare os.tmpdir())
84
+ // breaks exactly the same way the trap this file exists to test does —
85
+ // verified empirically (a real spawn against a deliberately spacey temp
86
+ // dir) that wrapping the executable path in quotes fixes it, mirroring
87
+ // how any real ops-configured CLAUDE_BIN with a space would also need
88
+ // quoting. Production's own default (`bin = 'claude'`, a bare PATH
89
+ // lookup) never hits this, so it stays out of runClaudeCli itself.
90
+ const claudeBin = process.platform === 'win32' ? `"${bin}"` : bin;
91
+ const r = await subagent.runSubagent({ prompt: 'hi', opts: { claudeBin, tools: '', model: 'claude-sonnet-5' } });
92
+ const argv = JSON.parse(subagent.extractAssistantResult(r.stdout));
93
+ const toolsIdx = argv.indexOf('--tools');
94
+ assert.ok(toolsIdx >= 0, '--tools made it into argv at all');
95
+ assert.equal(argv[toolsIdx + 1], '', 'the value right after --tools is a genuine empty string');
96
+ assert.ok(argv.includes('--strict-mcp-config'), '--strict-mcp-config is its own argv element, never swallowed as --tools\' value');
97
+ } finally {
98
+ fs.rmSync(dir, { recursive: true, force: true });
99
+ }
100
+ });
101
+
102
+ summary();
@@ -101,9 +101,10 @@ function bootPanel({ answers = {}, writes = null, mode = 'light' } = {}) {
101
101
  return val.__status ? { ok: false, status: val.__status, data: val.data } : { ok: true, status: 200, data: val };
102
102
  },
103
103
  };
104
- const win = { matchMedia: () => ({ matches: false }) };
104
+ const win = { matchMedia: () => ({ matches: false }), __BRANDING__: { identity: { productName: 'Cloud Bongos', worldName: 'the studio' } } };
105
105
  const ctx = vm.createContext({ window: win, document: documentObj, console, setTimeout, clearTimeout, encodeURIComponent });
106
106
  win.window = win;
107
+ vm.runInContext(read('brand-holes.js'), ctx, { filename: 'brand-holes.js' });
107
108
  vm.runInContext(read('approval-queue-lib.js'), ctx, { filename: 'approval-queue-lib.js' });
108
109
  vm.runInContext(read('approval-queue.js'), ctx, { filename: 'approval-queue.js' });
109
110
  const panel = win.OTBApprovalQueue.mount({
@@ -320,8 +321,59 @@ await test('the panel writes only through the writes it is handed; the studio ha
320
321
  assert.match(studio, /sendBack: \(pageId\) => `\$\{API\}\/copy-desk\/pages\/\$\{pageId\}\/send-back`,/);
321
322
  assert.match(studio, /writes: \{ approve: WRITE_ROUTES\.approve, sendBack: WRITE_ROUTES\.sendBack \}/);
322
323
  const html = read('studio.html');
323
- assert.ok(html.indexOf('approval-queue-lib.js') < html.indexOf('approval-queue.js"') && html.indexOf('approval-queue.js"') < html.indexOf('studio.js?'), 'the lib, the panel, then the studio');
324
+ assert.ok(html.indexOf('brand-holes.js') < html.indexOf('approval-queue.js?') && html.indexOf('approval-queue-lib.js') < html.indexOf('approval-queue.js?') && html.indexOf('approval-queue.js?') < html.indexOf('studio.js?'), 'the holes and the lib, the panel, then the studio');
324
325
  assert.match(html, /id="studio-approvals"[^>]*aria-haspopup="dialog"/);
325
326
  });
326
327
 
328
+ // ── task 1004381: brand holes read as the name, and the panel fits the screen ──
329
+
330
+ await test('a line holding {{productName}} reads as the product name in a chip, in the line and in its was:', async () => {
331
+ const tasks = [round({
332
+ id: 43, pageId: 'landing:index', n: 2, appliedAt: '2026-09-21T00:00:00Z',
333
+ lines: [{ key: 'L0005', section: 'contact', before: 'Both are how {{productName}} itself works.', after: 'This project, {{productName}}, has two doors.' }],
334
+ })];
335
+ const b = bootPanel({ answers: { [APPROVALS]: queueFor({ tasks }), [vis(43)]: slotsFor(43) } });
336
+ await b.panel.open();
337
+ await settle(); await settle();
338
+ // The mini-DOM's textContent does not keep a text node's place between
339
+ // children, so the words are read in order off the painted markup.
340
+ const words = (open, close) => {
341
+ const h = b.q('.aq-dialog').innerHTML;
342
+ const at = h.indexOf(open);
343
+ assert.ok(at !== -1, `painted: ${open}`);
344
+ return h.slice(at + open.length, h.indexOf(close, at)).replace(/<[^>]+>/g, '');
345
+ };
346
+ const lineWords = () => words('<span class="aq-line__text">', '</span>\n');
347
+ const asideWords = () => words('<span class="aq-line__aside">', '</span>\n');
348
+ assert.equal(lineWords(), 'This project, Cloud Bongos, has two doors.');
349
+ assert.equal(asideWords(), 'was: Both are how Cloud Bongos itself works.');
350
+ const chips = b.qa('.aq-hole');
351
+ assert.equal(chips.length, 2, 'one chip in the line, one in its was:');
352
+ assert.ok(chips.every((c) => c.attrs['data-hole'] === 'productName' && text(c) === 'Cloud Bongos'));
353
+ assert.ok(b.q('mark').querySelector('.aq-hole'), 'the chip sits inside the changed words\' mark');
354
+ assert.ok(!/\{\{/.test(b.scrim.innerHTML || '') && !/\{\{/.test(text(b.scrim)), 'no raw {{token}} anywhere in the panel');
355
+ b.scrim.fire('click', { target: b.q('[data-phase="before"]') }); await settle();
356
+ assert.equal(lineWords(), 'Both are how Cloud Bongos itself works.');
357
+ assert.equal(asideWords(), 'becomes: This project, Cloud Bongos, has two doors.');
358
+ });
359
+
360
+ await test('the dialog fits the viewport at every width: capped by the screen, the page shrinks first, rows wrap', () => {
361
+ const css = read('approval-queue.css');
362
+ const rule = (sel) => {
363
+ const at = css.indexOf(`\n${sel} {`);
364
+ assert.ok(at !== -1, `a ${sel} rule`);
365
+ return css.slice(at, css.indexOf('}', at));
366
+ };
367
+ const dialog = rule('.aq-dialog');
368
+ assert.match(dialog, /width: min\(1180px, 100%, calc\(100vw - 48px\)\)/, 'never wider than the screen less its gutters');
369
+ assert.match(dialog, /height: min\(820px, 100%, calc\(100dvh - 48px\)\)/, 'never taller than the screen');
370
+ assert.match(dialog, /min-width: 0/);
371
+ assert.match(rule('.aq-head'), /flex-wrap: wrap/, 'the header wraps rather than running off the edge');
372
+ assert.match(rule('.aq-foot'), /flex-wrap: wrap/);
373
+ assert.match(rule('.aq-body'), /min-width: 0/);
374
+ assert.match(rule('.aq-frame'), /flex-grow: 1;[\s\S]*min-width: 0/, 'the rendered page is what shrinks first');
375
+ assert.match(rule('.aq-lines'), /width: clamp\(240px, 32%, 300px\)/, 'the words keep the artboard\'s 300 until the page has shrunk');
376
+ assert.match(rule('.aq-line__text'), /overflow-wrap: anywhere/, 'a long word breaks inside its column');
377
+ });
378
+
327
379
  summary();
@@ -133,7 +133,9 @@ test('a toggle is a role=switch with aria-checked, and the page script keeps dat
133
133
  // toggle in task 1002518 (four rungs, not on/off), so it became a radio group
134
134
  // in settings-render.js — covered by the radio assertions and the wiring test
135
135
  // below, not by the switch contract.
136
- for (const fnName of ['renderSounds', 'renderCrossProject', 'privacyRow']) {
136
+ // renderCrossProject and privacyRow left with their sections (task 1004259):
137
+ // the hub's account page owns those settings now, and the hall links out.
138
+ for (const fnName of ['renderSounds']) {
137
139
  const body = settingsJs.match(new RegExp(`function ${fnName}\\([\\s\\S]*?\\n \\}\\n`));
138
140
  assert.ok(body && /switchHtml\(/.test(body[0]), `${fnName} renders the switch`);
139
141
  }