@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
@@ -398,3 +398,65 @@ test('a derived seven-day window is NOT paced against a boundary we invented', (
398
398
  assert.equal(d.derived, true);
399
399
  assert.equal(d.go, true);
400
400
  });
401
+
402
+ // --- a derived mark must be in the CURRENT window (task 1004376) ------------
403
+ //
404
+ // Regression from the 1004178 fix, found on the host the moment the runner was
405
+ // restarted onto it. derivedSince reported the RETIRED reading's resets_at,
406
+ // which on an unattended host is days old — so the caller's cap counted work
407
+ // done days earlier, arrived pre-exhausted, and the runner held forever having
408
+ // correctly derived headroom. Exactly the state 1004178 existed to end.
409
+
410
+ test('a week-old retired reading yields a mark inside the CURRENT window', () => {
411
+ const now = 10_000_000;
412
+ const weekAgoReset = now - 7 * 24 * HOUR; // the retired boundary
413
+ const d = decideCapacity({
414
+ gauge: {
415
+ written_at: weekAgoReset - HOUR,
416
+ five_hour: { used_percentage: 90, resets_at: weekAgoReset, observed_at: weekAgoReset - HOUR },
417
+ },
418
+ now,
419
+ });
420
+ assert.equal(d.derived, true);
421
+ assert.notEqual(d.derivedSince, weekAgoReset, 'the retired boundary is exactly what must NOT come back');
422
+ assert.ok(now - d.derivedSince < 5 * HOUR, 'the mark must sit inside the current five-hour window');
423
+ assert.ok(d.derivedSince <= now, 'and never in the future');
424
+ });
425
+
426
+ test('the advanced mark lands on a whole number of periods from the observed one', () => {
427
+ const now = 10_000_000;
428
+ const reset = now - (3 * 5 * HOUR) - HOUR; // three full windows, plus an hour
429
+ const d = decideCapacity({
430
+ gauge: { written_at: reset - 60, five_hour: { used_percentage: 80, resets_at: reset, observed_at: reset - 60 } },
431
+ now,
432
+ });
433
+ assert.equal(d.derivedSince, reset + 3 * 5 * HOUR, 'stepped forward by whole periods, not guessed');
434
+ });
435
+
436
+ test('derivedSince takes the MOST RECENT boundary when both windows derive', () => {
437
+ const now = 10_000_000;
438
+ const d = decideCapacity({
439
+ gauge: {
440
+ written_at: now - 8 * 24 * HOUR,
441
+ five_hour: { used_percentage: 50, resets_at: now - 6 * HOUR, observed_at: now - 8 * 24 * HOUR },
442
+ seven_day: { used_percentage: 50, resets_at: now - 8 * 24 * HOUR, observed_at: now - 8 * 24 * HOUR },
443
+ },
444
+ now,
445
+ });
446
+ assert.equal(d.derived, true);
447
+ assert.ok(now - d.derivedSince < 5 * HOUR,
448
+ 'the seven-day boundary would make the cap a one-time budget that never refills');
449
+ });
450
+
451
+ test('a reading whose window is still running is unaffected by the advance', () => {
452
+ const now = 10_000_000;
453
+ const d = decideCapacity({
454
+ gauge: {
455
+ written_at: now - 10 * HOUR,
456
+ five_hour: { used_percentage: 92, resets_at: now + HOUR, observed_at: now - 10 * HOUR },
457
+ },
458
+ now,
459
+ });
460
+ assert.equal(d.go, false, 'still unknown — usage only grows inside a live window');
461
+ assert.equal(d.unknown, true);
462
+ });
@@ -0,0 +1,275 @@
1
+ // tests/blocker_hall_live_proof.mjs — task 1003457 (BONGOS-V2, goal 1000089).
2
+ //
3
+ // CLAUDE.md states that resolving a blocker auto-promotes any linked `blocked`
4
+ // task back to `ready`, via a DB TRIGGER (migration 009, tightened by core_223).
5
+ // Nothing had ever driven that trigger through the HALL'S OWN PATH: the real
6
+ // HTTP routes modules/hall-ui/public/blockers.js calls (POST /blockers,
7
+ // POST /blockers/:id/link, POST /blockers/:id/resolve). The existing coverage
8
+ // is real but narrower — blocker_resolve_guard.mjs and blocker_unlink.mjs pin
9
+ // the migrations' SQL TEXT statically (no live trigger ever fires), and
10
+ // blocker_link.mjs unit-tests the pure `blocksOnLink` predicate the route's
11
+ // SQL is built from (its own header: "The SQL itself is exercised by the
12
+ // post-deploy smoke run"). None of them prove the trigger actually fires when
13
+ // a write arrives through the real router, its own middleware stack and its
14
+ // own pooled connection — prove it end to end rather than reasoning about it.
15
+ //
16
+ // So this: real Postgres, the real buildGdsRouter() booted in-process (the
17
+ // government_live_proof.mjs pattern), mounted at the CANONICAL prefix
18
+ // (src/bongos/api-prefix.js API_PREFIX = '/api/bongos') so the path is
19
+ // byte-identical to what the hall UI calls — '/api/gds' is only a permanent
20
+ // alias for older clients, and using it here would leave open exactly the
21
+ // "same handlers, different path" gap this proof exists to close. Real HTTP
22
+ // calls with a real bearer session — file a blocker, link it to a real
23
+ // `ready` task (parking it `blocked`), resolve the blocker THROUGH THE ROUTE,
24
+ // then independently re-read the task's status straight from the database.
25
+ // If the trigger is ever dropped, disconnected from this write path, or fired
26
+ // on a different transaction than the one the route actually commits, the
27
+ // task stays `blocked` and the assertion below fails loudly — not a skip,
28
+ // not a stub.
29
+ //
30
+ // WHY THIS CANNOT BE THE "always rolled back" CLASS (idea_credit_streams_db.mjs,
31
+ // core_258_box_session_claims_db.mjs): those call a module function directly
32
+ // with an injected client, so the whole test IS one transaction. Here the
33
+ // three acts are three separate HTTP requests, and the route handler opens
34
+ // ITS OWN connection from the real pool — a connection that can only see
35
+ // COMMITTED rows on another connection. Proving the trigger fires on the
36
+ // hall's real, independent connection therefore requires the fixture rows to
37
+ // really be committed; there is no transaction this file could wrap around
38
+ // three separate request/response round trips that would still let the
39
+ // server see them. The mitigation is scope and speed, not a rollback:
40
+ // high, unmistakably-synthetic fixture ids; the single most sensitive row
41
+ // (the live bearer session) is deleted FIRST in cleanup, before anything
42
+ // else, so the shortest possible cleanup still revokes it; and cleanup runs
43
+ // via a tight id/source_ref match, never a free-text title scan.
44
+ //
45
+ // Real-DB integration test (the needs_rebase.mjs class): SELF-SKIPS (exit 0)
46
+ // when DATABASE_URL is unset/unreachable or the schema it needs is missing,
47
+ // and is listed in run-unit-tests.js's INTEGRATION set so the DB-free lane
48
+ // never runs it. DATABASE_URL is what the sibling integration tests in this
49
+ // set (claim_gate_open_blocker.mjs, government_live_proof.mjs) already treat
50
+ // as "this is a throwaway CI/dev Postgres" — production never sets it
51
+ // (src/bongos/pool.js resolves an unset DATABASE_URL to the peer-auth local
52
+ // socket instead), so the same assumption applies here.
53
+ //
54
+ // Run: DATABASE_URL=... node tests/blocker_hall_live_proof.mjs
55
+
56
+ import { strict as assert } from 'node:assert';
57
+ import crypto from 'node:crypto';
58
+ import { createRequire } from 'node:module';
59
+
60
+ process.env.NODE_ENV = process.env.NODE_ENV || 'test';
61
+ const require = createRequire(import.meta.url);
62
+
63
+ if (!process.env.DATABASE_URL) {
64
+ console.log('blocker_hall_live_proof.mjs SKIPPED — DATABASE_URL not set.');
65
+ process.exit(0);
66
+ }
67
+
68
+ const { Pool } = require('pg');
69
+ const { sessionTokenHash } = require('../src/bongos/db-kernel.js');
70
+ const { API_PREFIX } = require('../src/bongos/api-prefix.js');
71
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
72
+
73
+ // ── readiness guard (government_live_proof.mjs's shape, task 1003091's lesson:
74
+ // a schema this file cannot recognise must FAIL naming the tables, never skip
75
+ // silently as though there were simply no Postgres here) ────────────────────
76
+ const REQUIRED_TABLES = [
77
+ 'builders', 'builder_sessions', 'versions', 'tasks',
78
+ 'blockers', 'task_blockers',
79
+ 'government_builder_ranks', 'government_rank_permissions',
80
+ ];
81
+ try {
82
+ await pool.query('SELECT 1');
83
+ } catch (e) {
84
+ console.log(`blocker_hall_live_proof.mjs SKIPPED — Postgres unreachable (${e.code || e.message}).`);
85
+ await pool.end().catch(() => {});
86
+ process.exit(0);
87
+ }
88
+ {
89
+ const { rows } = await pool.query(
90
+ `SELECT table_name FROM information_schema.tables
91
+ WHERE table_schema = 'public' AND table_name = ANY($1::text[])`,
92
+ [REQUIRED_TABLES],
93
+ );
94
+ const present = new Set(rows.map((r) => r.table_name));
95
+ const missing = REQUIRED_TABLES.filter((t) => !present.has(t));
96
+ if (missing.length === REQUIRED_TABLES.length) {
97
+ console.log('blocker_hall_live_proof.mjs SKIPPED — reachable Postgres with no instance schema (run scripts/migrate.sh).');
98
+ await pool.end().catch(() => {});
99
+ process.exit(0);
100
+ }
101
+ if (missing.length) {
102
+ await pool.end().catch(() => {});
103
+ throw new Error(
104
+ `blocker_hall_live_proof.mjs CANNOT RUN: this database is migrated but is missing ${missing.join(', ')}. `
105
+ + 'Either the schema was renamed under this proof or the target is not a Cloud Bongos instance database.'
106
+ );
107
+ }
108
+ }
109
+
110
+ // ── fixtures (high-ID range; cleaned before AND after so reruns are safe) ────
111
+ const VER = 'ZZZ-BLK';
112
+ const BUILDER = 90000801;
113
+ const TASK = 90000802;
114
+ const RANK_KEY = 'metic'; // the seeded floor for blocker.review (catalog.js)
115
+ // A stable source/source_ref pair, never a free-text title, is what cleanup
116
+ // keys the blocker fixture on — a title match could reach any real blocker
117
+ // anyone else ever happened to file with the same words.
118
+ const BLOCKER_SOURCE = 'blocker_hall_live_proof';
119
+ const BLOCKER_SOURCE_REF = 'fixture';
120
+ const TOKEN = 'bhlp' + crypto.randomBytes(24).toString('hex');
121
+
122
+ const q = (sql, params) => pool.query(sql, params).then((r) => r.rows);
123
+
124
+ // Ordered so the single most sensitive row — the LIVE bearer session — goes
125
+ // first: even a cleanup that dies partway through has already revoked it.
126
+ // Every step is ATTEMPTED regardless of an earlier failure (collect, don't
127
+ // abort), and then a combined error is thrown if anything failed — so a
128
+ // cleanup failure surfaces loudly (naming what actually blocked it, matching
129
+ // government_live_proof.mjs's shape) without one bad DELETE stranding every
130
+ // row after it.
131
+ async function cleanup() {
132
+ const steps = [
133
+ ['builder_sessions', () => q(`DELETE FROM builder_sessions WHERE builder_id = $1`, [BUILDER])],
134
+ ['task_blockers', () => q(`DELETE FROM task_blockers WHERE task_id = $1`, [TASK])],
135
+ ['blockers', () => q(`DELETE FROM blockers WHERE source = $1 AND source_ref = $2`, [BLOCKER_SOURCE, BLOCKER_SOURCE_REF])],
136
+ ['tasks', () => q(`DELETE FROM tasks WHERE id = $1`, [TASK])],
137
+ ['government_builder_ranks', () => q(`DELETE FROM government_builder_ranks WHERE builder_id = $1`, [BUILDER])],
138
+ ['audit_log', () => q(`DELETE FROM audit_log WHERE builder_id = $1`, [BUILDER])],
139
+ ['builders', () => q(`DELETE FROM builders WHERE id = $1`, [BUILDER])],
140
+ ['versions', () => q(`DELETE FROM versions WHERE id = $1`, [VER])],
141
+ ];
142
+ const failures = [];
143
+ for (const [name, run] of steps) {
144
+ try { await run(); } catch (e) { failures.push(`${name}: ${e.message}`); }
145
+ }
146
+ if (failures.length) throw new Error(`cleanup: ${failures.length} step(s) failed — ${failures.join('; ')}`);
147
+ }
148
+
149
+ async function seed() {
150
+ // `shipped`, deliberately not `planning` — the task's own row only needs a
151
+ // version_id FK target, and versions_one_planning_idx (core_233) is a
152
+ // partial UNIQUE index allowing exactly one `planning` row in the whole
153
+ // table. `ON CONFLICT (id)` guards only the primary key, not that second
154
+ // index, so seeding `planning` here would 23505 on any database that
155
+ // already has one in progress (nearly every real dev/CI database does).
156
+ // `shipped` carries no such cardinality constraint.
157
+ await q(
158
+ `INSERT INTO versions (id,name,status,track) VALUES ($1,'blk','shipped','internal')
159
+ ON CONFLICT (id) DO NOTHING`,
160
+ [VER],
161
+ );
162
+ await q(
163
+ `INSERT INTO builders (id,github_id,github_login,display_name,rank,status)
164
+ VALUES ($1,'bhlp-builder','bhlp-builder','BHLP Fixture Builder','metic','active')`,
165
+ [BUILDER],
166
+ );
167
+ // R95b: the resolver reads government_builder_ranks, not builders.rank — a
168
+ // direct seed has to mirror the rank-role sync the same way
169
+ // government_live_proof.mjs does, or the fixture builder resolves ZERO
170
+ // permissions and 403s its own /resolve and /link calls.
171
+ await q(
172
+ `INSERT INTO government_builder_ranks (builder_id, rank_key) VALUES ($1,$2)`,
173
+ [BUILDER, RANK_KEY],
174
+ );
175
+ await q(
176
+ `INSERT INTO builder_sessions (builder_id, token_hash, source) VALUES ($1,$2,'web')`,
177
+ [BUILDER, sessionTokenHash(TOKEN)],
178
+ );
179
+ // A `ready` task — the one status blockers.linkBlockerToTask actually parks
180
+ // as `blocked` (LINK_BLOCKABLE_STATUSES), so the resolve trigger has
181
+ // something real to promote back. The discipline is named because the column
182
+ // default is still 'unclassified', which tasks_discipline_check has refused
183
+ // since core_247 (task 1002988); every real create path resolves one.
184
+ await q(
185
+ `INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,discipline)
186
+ VALUES ($1,$2,'bhlp fixture task','ready',10,'feature','engineer')`,
187
+ [TASK, VER],
188
+ );
189
+ }
190
+
191
+ // ── the server: the real router, in-process, at the CANONICAL prefix ────────
192
+ const express = require('express');
193
+ const { buildGdsRouter } = require('../src/bongos/routes.js');
194
+ const app = express();
195
+ app.use(API_PREFIX, buildGdsRouter());
196
+ const server = await new Promise((resolve) => {
197
+ const s = app.listen(0, '127.0.0.1', () => resolve(s));
198
+ });
199
+ const base = `http://127.0.0.1:${server.address().port}`;
200
+
201
+ const call = async (method, path, body) => {
202
+ const res = await fetch(`${base}${API_PREFIX}${path}`, {
203
+ method,
204
+ headers: {
205
+ authorization: `Bearer ${TOKEN}`,
206
+ ...(body ? { 'content-type': 'application/json' } : {}),
207
+ },
208
+ body: body ? JSON.stringify(body) : undefined,
209
+ });
210
+ const json = await res.json().catch(() => ({}));
211
+ // Status + error code only — never the body, so an auth-failure response
212
+ // can never print session material into this test's own log.
213
+ const code = json?.error?.code || json?.error || '';
214
+ console.log(` ${method.padEnd(6)} ${path} → ${res.status}${code ? ` ${code}` : ''}`);
215
+ return { status: res.status, body: json };
216
+ };
217
+
218
+ const taskStatus = async () => {
219
+ const rows = await q(`SELECT status FROM tasks WHERE id = $1`, [TASK]);
220
+ return rows[0]?.status;
221
+ };
222
+
223
+ let pass = 0;
224
+ const ok = (cond, msg) => { assert.ok(cond, msg); console.log(` ok ${msg}`); pass++; };
225
+
226
+ try {
227
+ await cleanup();
228
+ await seed();
229
+ console.log(`blocker_hall_live_proof against ${base}${API_PREFIX} (task fixture #${TASK})`);
230
+
231
+ ok((await taskStatus()) === 'ready', 'BEFORE: the fixture task starts ready');
232
+
233
+ // Act 1 — file the blocker, through the route (any builder may file).
234
+ let r = await call('POST', '/blockers', {
235
+ title: 'blocker_hall_live_proof fixture blocker',
236
+ source: BLOCKER_SOURCE,
237
+ source_ref: BLOCKER_SOURCE_REF,
238
+ });
239
+ ok(r.status === 201 && r.body?.blocker?.id, 'POST /blockers files the blocker (201)');
240
+ const blockerId = r.body.blocker.id;
241
+
242
+ // Act 2 — link it to the task, through the route. This is what parks the
243
+ // task `blocked` — the state the resolve-side trigger has to release it from.
244
+ r = await call('POST', `/blockers/${blockerId}/link`, { task_id: TASK });
245
+ ok(r.status === 200 && r.body?.blocked === true, 'POST /blockers/:id/link blocks the task (200, blocked:true)');
246
+ ok((await taskStatus()) === 'blocked', 'the task is independently confirmed BLOCKED after the link');
247
+
248
+ // Act 3 — resolve it, through the route. THE PROOF: no code here ever issues
249
+ // `UPDATE tasks SET status = 'ready'` — only the DB trigger can move it.
250
+ r = await call('POST', `/blockers/${blockerId}/resolve`, { resolution_note: 'live proof' });
251
+ ok(r.status === 200, 'POST /blockers/:id/resolve succeeds (200)');
252
+ ok(Array.isArray(r.body?.promoted_tasks) && r.body.promoted_tasks.some((t) => Number(t.id) === TASK),
253
+ 'the resolve response itself reports the task as promoted');
254
+
255
+ // Act 4 — THE independent proof: re-read straight from Postgres, off the
256
+ // route entirely. If the trigger never fired on this write path — wrong
257
+ // transaction, wrong connection, dropped migration — this is where it shows.
258
+ ok((await taskStatus()) === 'ready',
259
+ 'AFTER: the linked task is independently confirmed READY — the DB trigger fired on the hall path');
260
+
261
+ console.log(`\nblocker_hall_live_proof (task 1003457): ${pass} assertions passed`);
262
+ } catch (e) {
263
+ console.error('blocker_hall_live_proof.mjs FAILED:', e.stack || e.message);
264
+ process.exitCode = 1;
265
+ } finally {
266
+ // A failed final cleanup can strand the fixture builder/session for the
267
+ // NEXT run — that is a real problem for this test, not a footnote, so it
268
+ // fails the run rather than only logging.
269
+ await cleanup().catch((e) => {
270
+ console.error('cleanup failed:', e.message);
271
+ process.exitCode = 1;
272
+ });
273
+ await new Promise((resolve) => server.close(resolve));
274
+ await pool.end().catch(() => {});
275
+ }
@@ -0,0 +1,116 @@
1
+ // tests/claim_gate_ci_unblock.mjs — the owed-rebase gate's one exemption (task 1004370).
2
+ //
3
+ // When main's CI goes red for a reason outside every branch, no confirmed task can
4
+ // land, so ADR 0120 part 3's gate refuses the builder every new claim — including
5
+ // the fix that would let their confirmed task land. A P1 kind=infra task (the shape
6
+ // a CI-unblock is filed in) passes the gate; nothing else does.
7
+ //
8
+ // Two altitudes: the pure rule is RUN here, and the wiring into claimTask is driven
9
+ // through the real function with a fake pool whose dispatch is exact per statement,
10
+ // so a gate that stopped consulting the rule (or consulted it for the wrong task)
11
+ // reds here without a Postgres. The real-DB proof is in tests/claim_gate_rebase.mjs.
12
+ import { strict as assert } from 'node:assert';
13
+ import { createRequire } from 'node:module';
14
+
15
+ const require = createRequire(import.meta.url);
16
+
17
+ let passed = 0;
18
+ let failed = 0;
19
+ async function t(name, fn) {
20
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
21
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.stack || e.message}`); }
22
+ }
23
+
24
+ const { isRebaseGateExempt } = require('../modules/lifecycle/db-claims.js');
25
+
26
+ await t('a P1 infra task is exempt', () => {
27
+ assert.equal(isRebaseGateExempt({ kind: 'infra', priority: 1 }), true);
28
+ });
29
+
30
+ await t('the DB hands priority back as a string sometimes, and that still counts', () => {
31
+ assert.equal(isRebaseGateExempt({ kind: 'infra', priority: '1' }), true);
32
+ });
33
+
34
+ await t('both halves are required: infra at any other priority stays gated', () => {
35
+ for (const priority of [2, 3, 5, null, undefined]) {
36
+ assert.equal(isRebaseGateExempt({ kind: 'infra', priority }), false, `infra P${priority}`);
37
+ }
38
+ });
39
+
40
+ await t('both halves are required: a P1 of any other kind stays gated', () => {
41
+ for (const kind of ['bug', 'feature', 'refactor', 'decision', 'spike', null]) {
42
+ assert.equal(isRebaseGateExempt({ kind, priority: 1 }), false, `P1 ${kind}`);
43
+ }
44
+ });
45
+
46
+ await t('a missing task row is never exempt', () => {
47
+ assert.equal(isRebaseGateExempt(undefined), false);
48
+ assert.equal(isRebaseGateExempt(null), false);
49
+ });
50
+
51
+ // ---- the wiring: claimTask actually consults the rule ------------------------
52
+ // The gate throws before claimTask reaches anything else, so the fake only has to
53
+ // answer the queries up to that point. An unexpected statement throws, which is
54
+ // what makes a green here mean the path really ran.
55
+ const dbPath = require.resolve('../src/module-api.js');
56
+ function fakeClientFor(task) {
57
+ const seen = [];
58
+ const client = {
59
+ seen,
60
+ async query(sql, params) {
61
+ const s = String(sql).replace(/\s+/g, ' ').trim();
62
+ seen.push(s);
63
+ if (s === 'BEGIN' || s === 'ROLLBACK' || s === 'COMMIT') return { rows: [] };
64
+ if (s.startsWith('SELECT status, rank FROM builders')) return { rows: [{ status: 'active', rank: 'archon' }] };
65
+ if (s.startsWith('SELECT id, title, needs_rebase_reason, needs_rebase_at FROM tasks WHERE needs_rebase = true')) {
66
+ return { rows: [{ id: '900', title: 'stalled', needs_rebase_reason: 'strand:pr_not_landed', needs_rebase_at: new Date() }] };
67
+ }
68
+ if (s === 'SELECT kind, priority FROM tasks WHERE id = $1') {
69
+ assert.equal(String(params[0]), '901', 'the rule is asked about the task being CLAIMED, not the stalled one');
70
+ return { rows: task ? [task] : [] };
71
+ }
72
+ // Past the gate: an exempt claim reaches the task read. Stop there, loudly
73
+ // and recognisably, so the test can tell "passed the gate" from any error.
74
+ if (s.startsWith('SELECT id, status, touches')) throw Object.assign(new Error('past the gate'), { code: 'PAST_GATE' });
75
+ throw new Error(`unexpected statement: ${s.slice(0, 120)}`);
76
+ },
77
+ release() {},
78
+ };
79
+ return client;
80
+ }
81
+
82
+ async function claimWith(task) {
83
+ const client = fakeClientFor(task);
84
+ const poolMod = require(dbPath);
85
+ const saved = poolMod.pool.connect;
86
+ poolMod.pool.connect = async () => client;
87
+ try {
88
+ const { claimTask } = require('../modules/lifecycle/db-claims.js');
89
+ await claimTask({ taskId: '901', builderId: '77', worktreeName: 'wt' });
90
+ return { client, err: null };
91
+ } catch (err) {
92
+ return { client, err };
93
+ } finally {
94
+ poolMod.pool.connect = saved;
95
+ }
96
+ }
97
+
98
+ await t('a gated builder claiming an ordinary task is refused REBASE_REQUIRED', async () => {
99
+ const { err } = await claimWith({ kind: 'feature', priority: 1 });
100
+ assert.equal(err && err.code, 'REBASE_REQUIRED');
101
+ assert.ok(err.rebaseTasks.some((r) => String(r.id) === '900'), 'and the refusal still names what is owed');
102
+ });
103
+
104
+ await t('a gated builder claiming a P1 infra task goes PAST the gate', async () => {
105
+ const { err, client } = await claimWith({ kind: 'infra', priority: 1 });
106
+ assert.equal(err && err.code, 'PAST_GATE', `expected to reach the task read, got ${err && (err.code || err.message)}`);
107
+ assert.ok(client.seen.includes('SELECT kind, priority FROM tasks WHERE id = $1'), 'the rule was consulted');
108
+ });
109
+
110
+ await t('a gated builder claiming a task that does not exist still meets the gate first', async () => {
111
+ const { err } = await claimWith(null);
112
+ assert.equal(err && err.code, 'REBASE_REQUIRED');
113
+ });
114
+
115
+ console.log(`\nclaim_gate_ci_unblock: ${passed} passed, ${failed} failed`);
116
+ process.exit(failed ? 1 : 0);
@@ -30,13 +30,15 @@ const db = require('../modules/lifecycle/db.js');
30
30
  const VER = 'ZZZ-CG', WORKER = 90000201, STALLED = 90000201, FRESH = 90000202;
31
31
  // task 1003294 — the unprovable-strand cases below use their own rows.
32
32
  const AGED = 90000203, FRESH2 = 90000204, AGED_OTHER = 90000205;
33
+ // task 1004370 — the CI-unblock exemption.
34
+ const CIFIX = 90000206;
33
35
  const q = (sql, params) => pool.query(sql, params).then((r) => r.rows);
34
36
 
35
37
  async function cleanup() {
36
38
  await q(`DELETE FROM builder_achievements WHERE builder_id=$1`, [WORKER]).catch(() => {});
37
- await q(`DELETE FROM credit_log WHERE builder_id=$1 OR task_id = ANY($2::bigint[])`, [WORKER, [STALLED, FRESH, AGED, FRESH2, AGED_OTHER]]).catch(() => {});
38
- await q(`DELETE FROM claims WHERE task_id = ANY($1::bigint[])`, [[STALLED, FRESH, AGED, FRESH2, AGED_OTHER]]).catch(() => {});
39
- await q(`DELETE FROM tasks WHERE id = ANY($1::bigint[])`, [[STALLED, FRESH, AGED, FRESH2, AGED_OTHER]]).catch(() => {});
39
+ await q(`DELETE FROM credit_log WHERE builder_id=$1 OR task_id = ANY($2::bigint[])`, [WORKER, [STALLED, FRESH, AGED, FRESH2, AGED_OTHER, CIFIX]]).catch(() => {});
40
+ await q(`DELETE FROM claims WHERE task_id = ANY($1::bigint[])`, [[STALLED, FRESH, AGED, FRESH2, AGED_OTHER, CIFIX]]).catch(() => {});
41
+ await q(`DELETE FROM tasks WHERE id = ANY($1::bigint[])`, [[STALLED, FRESH, AGED, FRESH2, AGED_OTHER, CIFIX]]).catch(() => {});
40
42
  await q(`DELETE FROM builders WHERE id=$1`, [WORKER]).catch(() => {});
41
43
  await q(`DELETE FROM versions WHERE id=$1`, [VER]).catch(() => {});
42
44
  }
@@ -139,6 +141,16 @@ try {
139
141
  ok(threw2 && threw2.code === 'REBASE_REQUIRED',
140
142
  'an aged pr_not_landed strand still gates — the builder CAN fix that one');
141
143
 
144
+ // 7) task 1004370 — the one task the gate lets through: a P1 kind=infra fix,
145
+ // the shape a CI-unblock is filed in. With AGED_OTHER still gating, claiming
146
+ // it succeeds; FRESH2 (a P1 feature) was refused in 6), so both halves count.
147
+ await q(`INSERT INTO tasks (id,version_id,title,status,credits_reward,kind,priority) VALUES ($1,$2,'ci fix','ready',10,'infra',1)`, [CIFIX, VER]);
148
+ const ciClaim = await db.claimTask({ taskId: String(CIFIX), builderId: String(WORKER), worktreeName: 'cg-cifix' });
149
+ ok(ciClaim && Number(ciClaim.task_id) === CIFIX,
150
+ 'a P1 infra task is claimable while a rebase is owed — the CI-unblock fix is never gated out');
151
+ ok((await db.needsRebaseTasksForBuilder(String(WORKER))).some((t) => Number(t.id) === AGED_OTHER),
152
+ 'and the owed rebase is still reported: the exemption lets one claim through, it clears nothing');
153
+
142
154
  console.log(`\nclaim_gate_rebase (ADR 0120 part 3): ${pass} passed, 0 failed`);
143
155
  } catch (e) {
144
156
  console.error('claim_gate_rebase.mjs FAILED:', e.stack || e.message);
@@ -590,6 +590,16 @@ test('an ask can be answered — and the answer surface stops well short of a ch
590
590
  assert.ok(!/parent_reply|data-reply-to|@mention|mentions/i.test(js), 'no nesting and no mentions');
591
591
  });
592
592
 
593
+ // Task 1004234: the reply box once hinted with a leftover reply from one
594
+ // thread, which every reader took for a real message about something they had
595
+ // never heard of. The hint says what the box is for, and nothing more.
596
+ test('the reply box hint is neutral, not a stray reply from some thread', () => {
597
+ const js = read('modules', 'hall-ui', 'public', 'collab.js');
598
+ const m = js.match(/class="cb-reply-input"[^>]*placeholder="([^"]*)"/);
599
+ assert.ok(m, 'the reply box carries a placeholder');
600
+ assert.equal(m[1], 'Write a reply…');
601
+ });
602
+
593
603
  // RUN, not grepped — this file's own convention for everything in collab-lib.js,
594
604
  // and the grader was right that the first pass broke it. canClose in particular
595
605
  // decides whether a close control is drawn at all, so each branch of it needs a
@@ -0,0 +1,97 @@
1
+ // tests/conductor_main_e2e.mjs — the Conductor hook, run END TO END as
2
+ // settings.json runs it (task 1004398).
3
+ //
4
+ // Every other Conductor test drives evaluateTriggers() with hand-built inputs.
5
+ // That is how task 1003855 shipped a main() that passed an undeclared
6
+ // `specialities` into it: the hook threw ReferenceError on every prompt,
7
+ // `main().catch(() => {})` swallowed it, and for thirteen days no session got
8
+ // its interaction profile, its focus contract, the no-claim warning or the
9
+ // Hacker dispatch — with every Conductor test green. This file runs the real
10
+ // hook as a child process against a stub GET /me, so a crash anywhere between
11
+ // the fetch and the render shows up as missing output.
12
+ //
13
+ // Run: node tests/conductor_main_e2e.mjs
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { createRequire } from 'node:module';
17
+ import { spawn } from 'node:child_process';
18
+ import http from 'node:http';
19
+ import fs from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { makeRunner } from './helpers.mjs';
24
+
25
+ const require = createRequire(import.meta.url);
26
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
27
+ const HOOK = path.join(ROOT, '.claude', 'hooks', 'conductor.js');
28
+ const ic = require('../src/instance-config.js');
29
+ const { test, summary } = makeRunner();
30
+
31
+ const INTERACTION = 'PROFILE-CONTRACT: pitch replies for a non-technical reader.';
32
+ const SPECIALITY = 'SPECIALITY-CONTRACT: the engineer expert contract.';
33
+ const ARTIST_SPECIALITY = 'ARTIST-CONTRACT: must not appear in an engineering session.';
34
+
35
+ // The /me payload the hook consumes, in the route's own shape.
36
+ function mePayload({ discipline = 'engineer' } = {}) {
37
+ return {
38
+ builder: { id: 4242, rank: 'metic' },
39
+ active_claims: [{ task_id: 77, title: 'a claimed task', touches: [], worktree_name: 'task-77', discipline }],
40
+ wandering: { effective_level: 'normal', inject: false, contract: '' },
41
+ interaction: { inject: true, contract: INTERACTION },
42
+ specialities: [
43
+ { discipline: 'engineer', inject: true, contract: SPECIALITY },
44
+ { discipline: 'artist', inject: true, contract: ARTIST_SPECIALITY },
45
+ ],
46
+ };
47
+ }
48
+
49
+ // Run the hook once in a throwaway HOME whose session points at `base`.
50
+ async function runHook(me) {
51
+ const server = http.createServer((req, res) => {
52
+ if (req.url.startsWith('/api/bongos/me')) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(me)); return; }
53
+ res.writeHead(404); res.end('{}');
54
+ });
55
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
56
+ const base = `http://127.0.0.1:${server.address().port}`;
57
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'conductor-e2e-'));
58
+ // The session lives where the CLI looks for it, re-rooted under the fake home.
59
+ const rel = path.relative(os.homedir(), ic.configPath('gds-session.json'));
60
+ const sessionPath = path.join(home, rel);
61
+ fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
62
+ fs.writeFileSync(sessionPath, JSON.stringify({ token: 'test-token', api_base: base, builder: { id: 4242 } }));
63
+ const env = { ...process.env, HOME: home, USERPROFILE: home };
64
+ for (const k of Object.keys(env)) if (/_API_BASE$/.test(k) || k === 'OTB_CONDUCTOR_OFF' || k === 'OTB_SUBAGENT') delete env[k];
65
+ try {
66
+ const child = spawn(process.execPath, [HOOK], { env, cwd: home, stdio: ['pipe', 'pipe', 'pipe'] });
67
+ let out = ''; let err = '';
68
+ child.stdout.on('data', (d) => { out += d; });
69
+ child.stderr.on('data', (d) => { err += d; });
70
+ child.stdin.end(JSON.stringify({ session_id: 'e2e-' + Date.now(), prompt: 'hello', cwd: home }));
71
+ const code = await new Promise((r) => child.on('close', r));
72
+ return { out, err, code };
73
+ } finally {
74
+ await new Promise((r) => server.close(r));
75
+ fs.rmSync(home, { recursive: true, force: true });
76
+ }
77
+ }
78
+
79
+ await test('the hook, run as settings.json runs it, injects the interaction profile (task 1004398)', async () => {
80
+ const r = await runHook(mePayload());
81
+ assert.equal(r.code, 0, 'the hook never fails the prompt');
82
+ assert.ok(r.out.includes(INTERACTION), `the interaction contract reaches the session; stdout was: ${JSON.stringify(r.out.slice(0, 300))}`);
83
+ });
84
+
85
+ await test('the speciality contract for the CLAIMED craft reaches the session, and no other craft\'s', async () => {
86
+ const r = await runHook(mePayload({ discipline: 'engineer' }));
87
+ assert.ok(r.out.includes(SPECIALITY), 'the engineer speciality is injected for an engineering claim');
88
+ assert.ok(!r.out.includes(ARTIST_SPECIALITY), 'the artist contract is not');
89
+ });
90
+
91
+ await test('with an artist claim, the artist contract is the one injected', async () => {
92
+ const r = await runHook(mePayload({ discipline: 'artist' }));
93
+ assert.ok(r.out.includes(ARTIST_SPECIALITY));
94
+ assert.ok(!r.out.includes(SPECIALITY));
95
+ });
96
+
97
+ summary();