@bongos/core 1.19.1081 → 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 (251) hide show
  1. package/.bongos-core.json +526 -191
  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 +6 -0
  24. package/docs/modules-contract.md +1 -0
  25. package/docs/page-inventory.json +38 -4
  26. package/docs/page-readings.json +1674 -1566
  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/goals.html +1 -1
  51. package/modules/hall-ui/public/government.html +1 -1
  52. package/modules/hall-ui/public/idea.html +1 -1
  53. package/modules/hall-ui/public/idea.js +28 -0
  54. package/modules/hall-ui/public/ideas.html +1 -1
  55. package/modules/hall-ui/public/ideas.js +19 -3
  56. package/modules/hall-ui/public/index.html +5 -1
  57. package/modules/hall-ui/public/modules.html +1 -1
  58. package/modules/hall-ui/public/primer.html +1 -1
  59. package/modules/hall-ui/public/profile-nudge.js +21 -4
  60. package/modules/hall-ui/public/profile.html +1 -1
  61. package/modules/hall-ui/public/profile.js +1 -1
  62. package/modules/hall-ui/public/project-settings.html +1 -1
  63. package/modules/hall-ui/public/ranks.html +1 -1
  64. package/modules/hall-ui/public/roadmap.html +1 -1
  65. package/modules/hall-ui/public/roster.html +1 -1
  66. package/modules/hall-ui/public/sessions.html +1 -1
  67. package/modules/hall-ui/public/settings.html +19 -30
  68. package/modules/hall-ui/public/settings.js +24 -177
  69. package/modules/hall-ui/public/settings.states.json +1 -1
  70. package/modules/hall-ui/public/shell.js +4 -2
  71. package/modules/hall-ui/public/studio.css +14 -8
  72. package/modules/hall-ui/public/studio.html +5 -4
  73. package/modules/hall-ui/public/task.html +1 -1
  74. package/modules/hall-ui/public/thinking.css +6 -3
  75. package/modules/hall-ui/public/thinking.html +1 -1
  76. package/modules/hall-ui/public/tweak-editor-lib.js +82 -1
  77. package/modules/hall-ui/public/tweak-editor.css +142 -11
  78. package/modules/hall-ui/public/tweak-editor.html +25 -9
  79. package/modules/hall-ui/public/tweak-editor.js +243 -10
  80. package/modules/hall-ui/public/watch.html +1 -1
  81. package/modules/hall-ui/public/work.html +1 -1
  82. package/modules/hall-ui/records/approval-queue.md +6 -0
  83. package/modules/hall-ui/records/tweak-editor.md +6 -0
  84. package/modules/ideas/ratify.js +36 -8
  85. package/modules/ideas/routes/ratify-goal.js +6 -0
  86. package/modules/lifecycle/db-analytics.js +79 -10
  87. package/modules/lifecycle/db-claims.js +48 -1
  88. package/modules/lifecycle/db-deps-criteria.js +94 -40
  89. package/modules/lifecycle/db-goals.js +4 -1
  90. package/modules/lifecycle/db-grade.js +9 -1
  91. package/modules/lifecycle/db-relevance-flags.js +183 -0
  92. package/modules/lifecycle/db-versions.js +4 -3
  93. package/modules/lifecycle/db.js +19 -0
  94. package/modules/lifecycle/done-when.js +5 -3
  95. package/modules/lifecycle/est-advisory.js +31 -4
  96. package/modules/lifecycle/goal-task-relevance-advisory.js +153 -0
  97. package/modules/lifecycle/goal-task-relevance-judge.js +226 -0
  98. package/modules/lifecycle/migrations/lifecycle_014_goal_task_relevance_flags.sql +82 -0
  99. package/modules/lifecycle/module.json +2 -1
  100. package/modules/lifecycle/routes/claims.js +8 -1
  101. package/modules/lifecycle/routes/goal-task-relevance.js +72 -0
  102. package/modules/lifecycle/routes/goals.js +1 -1
  103. package/modules/lifecycle/routes/tasks.js +27 -24
  104. package/modules/lifecycle/routes/visuals.js +9 -0
  105. package/modules/lifecycle/ship-preflight.js +7 -11
  106. package/modules/lifecycle/task-visuals.js +109 -5
  107. package/modules/npm-release/module.json +2 -1
  108. package/modules/npm-release/preview/commands.js +81 -0
  109. package/modules/npm-release/preview/divert.js +95 -0
  110. package/modules/npm-release/preview/env.js +78 -0
  111. package/modules/npm-release/preview/proxy.js +107 -0
  112. package/modules/npm-release/preview/runtime.js +83 -0
  113. package/modules/npm-release/preview/supervisor.js +175 -0
  114. package/modules/npm-release/public/work.js +65 -1
  115. package/modules/npm-release/routes/preview.js +133 -0
  116. package/modules/provisioning/app-status.js +83 -0
  117. package/modules/provisioning/module.json +2 -1
  118. package/modules/provisioning/pollers/app-liveness.js +110 -0
  119. package/modules/provisioning/render-lookup.js +68 -0
  120. package/modules/provisioning/render-standup.js +44 -1
  121. package/modules/provisioning/routes/render-standup.js +81 -6
  122. package/modules/provisioning/starter-bundles.js +4 -1
  123. package/modules/public-landing/public/account-private.states.json +14 -0
  124. package/modules/public-landing/public/account.html +513 -0
  125. package/modules/public-landing/public/account.probes.json +37 -0
  126. package/modules/public-landing/public/account.states.json +16 -0
  127. package/modules/public-landing/public/index.html +5 -2
  128. package/modules/public-landing/public/projects.html +661 -18
  129. package/modules/public-landing/public/projects.states.json +8 -1
  130. package/modules/render-deploy/deploys.js +203 -0
  131. package/modules/render-deploy/migrations/render_deploy_001_app.sql +22 -0
  132. package/modules/render-deploy/module.json +25 -0
  133. package/modules/render-deploy/public/deploy.css +10 -0
  134. package/modules/render-deploy/public/deploy.js +249 -0
  135. package/modules/render-deploy/render.js +56 -0
  136. package/modules/render-deploy/routes/act.js +88 -0
  137. package/modules/render-deploy/routes/app.js +65 -0
  138. package/modules/render-deploy/routes/history.js +38 -0
  139. package/modules/specialities/db.js +27 -8
  140. package/modules/specialities/migrations/specialities_003_skills.sql +46 -0
  141. package/modules/specialities/routes/specialities.js +51 -3
  142. package/modules/specialities/skills.js +94 -0
  143. package/modules/specialities/specialities.js +56 -9
  144. package/modules/ui-design/kit/fixtures/me-cross-project-private.json +12 -0
  145. package/modules/ui-design/kit/fixtures/me__cross-project.json +15 -0
  146. package/modules/ui-design/kit/fixtures/provisioning-instance-render.json +39 -0
  147. package/modules/ui-design/kit/lib.js +3 -1
  148. package/modules/ui-design/kit/serve.js +96 -0
  149. package/package-lock.json +2 -2
  150. package/package.json +1 -1
  151. package/release-notes.json +163 -0
  152. package/scripts/gds/agents-sync.js +13 -3
  153. package/scripts/gds/autobongos-run.js +80 -2
  154. package/scripts/gds/autobongos-service.cmd +12 -0
  155. package/scripts/gds/copy-apply.js +14 -0
  156. package/scripts/gds/dev-box-guard.js +3 -2
  157. package/scripts/gds/fitness.js +9 -0
  158. package/scripts/gds/grade-correlation-audit.js +42 -12
  159. package/scripts/gds/grade-replay.js +1 -1
  160. package/scripts/gds/provision-core-upgrade.js +38 -7
  161. package/scripts/gds/provision-pin-key.js +176 -0
  162. package/scripts/gds/provision-render.js +62 -31
  163. package/scripts/gds/provision-teardown.js +7 -2
  164. package/scripts/gds/provision-units.js +31 -0
  165. package/scripts/gds/provision.js +10 -10
  166. package/scripts/gds/publish-manifest.js +1 -0
  167. package/scripts/gds/render-api.js +19 -0
  168. package/scripts/gds/render-payload.js +39 -65
  169. package/scripts/gds/run-unit-tests.js +10 -0
  170. package/scripts/gds/ship-finish.js +19 -20
  171. package/scripts/gds/smoke-dependencies.sh +37 -8
  172. package/scripts/gds/status.js +12 -4
  173. package/scripts/gds/upgrade.js +2 -2
  174. package/scripts/public-mirror-export.js +7 -1
  175. package/src/bongos/module-scope-map.js +13 -0
  176. package/src/bongos/serve-internal.js +3 -1
  177. package/src/module-api.js +10 -1
  178. package/src/platform-server.js +39 -0
  179. package/tests/account_privacy_flags.mjs +10 -6
  180. package/tests/agents_authoring.mjs +15 -1
  181. package/tests/agents_sync.mjs +106 -5
  182. package/tests/agents_validate.mjs +45 -0
  183. package/tests/api_path_404.mjs +6 -0
  184. package/tests/autobongos_cadence.mjs +7 -0
  185. package/tests/autobongos_loop.mjs +136 -1
  186. package/tests/autonomy_gauge.mjs +62 -0
  187. package/tests/blocker_hall_live_proof.mjs +275 -0
  188. package/tests/claim_gate_ci_unblock.mjs +116 -0
  189. package/tests/claim_gate_rebase.mjs +15 -3
  190. package/tests/collab_page.mjs +10 -0
  191. package/tests/conductor_main_e2e.mjs +97 -0
  192. package/tests/core_262_drop_box_blocked_db.mjs +143 -0
  193. package/tests/core_upgrade_runner.mjs +1 -0
  194. package/tests/dependency_writes_atomic.mjs +191 -0
  195. package/tests/deploy_page_projects.mjs +2 -1
  196. package/tests/effective_visibility_predicate.mjs +15 -0
  197. package/tests/est_advisory.mjs +27 -0
  198. package/tests/goal_map_page.mjs +26 -0
  199. package/tests/goal_task_relevance.mjs +518 -0
  200. package/tests/goal_working_area.mjs +146 -0
  201. package/tests/grade_attempts.mjs +61 -0
  202. package/tests/grade_attribution.mjs +23 -0
  203. package/tests/grade_correlation_audit.mjs +54 -1
  204. package/tests/grader_reader_lens.mjs +55 -2
  205. package/tests/grader_root_outage.mjs +6 -1
  206. package/tests/grader_subagent_tools_arg.mjs +102 -0
  207. package/tests/hall_approval_queue.mjs +54 -2
  208. package/tests/hall_settings_world.mjs +3 -1
  209. package/tests/hall_tweak_editor.mjs +316 -11
  210. package/tests/hub_account_page.mjs +394 -0
  211. package/tests/idea_detail_page.mjs +64 -0
  212. package/tests/idea_goal_ratification.mjs +64 -0
  213. package/tests/idea_spark_hall.mjs +60 -9
  214. package/tests/ideator_full_idea_shapes_space_proof.mjs +1 -1
  215. package/tests/landing_page.mjs +6 -3
  216. package/tests/module-scope-map.mjs +31 -8
  217. package/tests/module_api.mjs +1 -0
  218. package/tests/module_loader.mjs +1 -1
  219. package/tests/nav_permission_atoms.mjs +1 -1
  220. package/tests/no_phantom_mirror_workflow.mjs +24 -0
  221. package/tests/npm_release_preview_commands.mjs +89 -0
  222. package/tests/npm_release_preview_divert.mjs +164 -0
  223. package/tests/npm_release_preview_env.mjs +100 -0
  224. package/tests/npm_release_preview_proxy.mjs +162 -0
  225. package/tests/npm_release_preview_routes.mjs +208 -0
  226. package/tests/npm_release_preview_supervisor.mjs +203 -0
  227. package/tests/pin_write_key.mjs +383 -0
  228. package/tests/planning_session_skill.mjs +27 -0
  229. package/tests/platform_boot.mjs +71 -0
  230. package/tests/profile_nudge_links.mjs +86 -0
  231. package/tests/profile_ui_cross_project.mjs +1 -1
  232. package/tests/projects_hub.mjs +2 -2
  233. package/tests/projects_hub_app_status.mjs +249 -0
  234. package/tests/projects_hub_app_step.mjs +282 -52
  235. package/tests/projects_hub_render_connect.mjs +295 -0
  236. package/tests/provision_render.mjs +121 -28
  237. package/tests/provision_settings_apply.mjs +62 -0
  238. package/tests/provisioning_app_status.mjs +204 -0
  239. package/tests/provisioning_render_route.mjs +126 -6
  240. package/tests/render_check.mjs +2 -1
  241. package/tests/render_deploy.mjs +495 -0
  242. package/tests/ship_preflight.mjs +61 -8
  243. package/tests/smoke_dependencies_witness.mjs +46 -0
  244. package/tests/speciality_skills.mjs +214 -0
  245. package/tests/studio_room_height.mjs +70 -0
  246. package/tests/task_detail_includes.mjs +10 -0
  247. package/tests/task_visuals_instance_root.mjs +150 -0
  248. package/tests/tweak_batch_apply.mjs +21 -0
  249. package/tests/ui_design_kit.mjs +36 -4
  250. package/tests/upgrade.mjs +1 -1
  251. package/tests/upgrade_persist_pin.mjs +24 -0
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ // modules/render-deploy/routes/act.js — POST /render-deploy/act: deploy, or roll back (task 1004270).
4
+ //
5
+ // { action: 'deploy' | 'rollback', deployId?, confirm: true, expectLiveDeployId }
6
+ // with the owner's key in x-render-key.
7
+ //
8
+ // TWO STEPS, NEVER ONE, AND THE SERVER HOLDS THE SECOND (the /deploy door works the same way,
9
+ // ADR 0293 D4). Step one is the page showing what would happen and what it costs; step two is
10
+ // this request, which is refused unless it says `confirm: true` AND names the deploy that was
11
+ // live when the page drew step one. If Render's live deploy has moved since, the press is
12
+ // refused (409) and nothing is sent, so a confirm can never act on a picture that went stale.
13
+ // A rollback also has to name a deploy that is in this service's own list and was once live and
14
+ // then replaced; a deploy id from anywhere else is refused before any write.
15
+ //
16
+ // Gated on `core.pin.move` (see routes/app.js): the owner's hard gate over what runs for users.
17
+ // The one Render client has no delete, so neither act can remove anything on the owner's account.
18
+
19
+ const express = require('express');
20
+ const api = require('../../../src/module-api');
21
+ const { DEPLOY_RE, getApp, statusWords, ROLLBACK_STATUSES } = require('../deploys');
22
+ const { defaultMakeClient, keyFrom, refusal, renderFailure, NEEDS_KEY } = require('../render');
23
+
24
+ const log = api.logger('render-deploy');
25
+
26
+ module.exports = function buildRenderDeployActRouter({ makeClient = defaultMakeClient, pool } = {}) {
27
+ const router = express.Router();
28
+
29
+ router.post('/render-deploy/act', api.requireBuilder, api.requirePermission('core.pin.move'), async (req, res) => {
30
+ const db = pool || api.pool;
31
+ // Shape only, and strict: an undeclared field is refused. `confirm` and `expectLiveDeployId`
32
+ // are left to the checks below so each gets its own plain refusal rather than a schema one.
33
+ if (api.validateOrRespond(req, res, {
34
+ action: { type: 'string', maxLength: 16 },
35
+ deployId: { type: 'string', maxLength: 80 },
36
+ confirm: {},
37
+ expectLiveDeployId: {},
38
+ })) return;
39
+ const body = req.body || {};
40
+ const action = body.action;
41
+ if (action !== 'deploy' && action !== 'rollback') return refusal(res, 400, 'bad_action', 'Say whether to deploy or roll back.');
42
+ if (body.confirm !== true) return refusal(res, 400, 'confirm_needed', 'Nothing was done. Confirm the step you were shown first.');
43
+ const expect = body.expectLiveDeployId;
44
+ if (expect !== null && !(typeof expect === 'string' && DEPLOY_RE.test(expect))) {
45
+ return refusal(res, 400, 'expectation_needed', 'Say which deploy was live when you were shown this step.');
46
+ }
47
+ if (action === 'rollback' && !(typeof body.deployId === 'string' && DEPLOY_RE.test(body.deployId))) {
48
+ return refusal(res, 400, 'bad_deploy_id', 'Say which deploy to roll back to.');
49
+ }
50
+ const key = keyFrom(req);
51
+ if (!key) return refusal(res, 400, 'render_key_needed', NEEDS_KEY);
52
+ try {
53
+ const app = await getApp(db);
54
+ if (!app) return refusal(res, 409, 'not_connected', 'This project is not connected to a Render service yet.');
55
+ const client = makeClient({ key, ownerId: app.owner_id });
56
+ const deploys = await client.listDeploys(app.service_id, { limit: 30 });
57
+ const liveNow = (deploys.find((d) => d.status === 'live') || {}).id || null;
58
+ if (liveNow !== expect) {
59
+ return refusal(res, 409, 'stale_history', 'What is live on Render changed since you were shown this step. Nothing was done. Read the history again.');
60
+ }
61
+ let started;
62
+ if (action === 'rollback') {
63
+ const target = deploys.find((d) => d.id === body.deployId);
64
+ if (!target) return refusal(res, 404, 'deploy_not_found', 'That deploy is not in this service\'s history.');
65
+ if (target.id === liveNow) return refusal(res, 409, 'already_live', 'That deploy is already the one running.');
66
+ if (!ROLLBACK_STATUSES.includes(target.status)) {
67
+ return refusal(res, 409, 'not_rollbackable', `That deploy cannot be rolled back to (${statusWords(target.status).toLowerCase()}). Only a deploy that ran and was then replaced can.`);
68
+ }
69
+ started = await client.rollbackTo(app.service_id, target.id);
70
+ } else {
71
+ started = await client.triggerDeploy(app.service_id);
72
+ }
73
+ // Confirmed on Render's side: read the service's newest deploy back rather than trust the
74
+ // 2xx alone, and hand it to the page as what Render says now.
75
+ let render = null;
76
+ try {
77
+ const after = await client.listDeploys(app.service_id, { limit: 3 });
78
+ const newest = after[0];
79
+ if (newest) render = { deployId: newest.id, status: newest.status, statusWords: statusWords(newest.status) };
80
+ } catch (e) { log.warn('read-back after the act failed'); }
81
+ res.json({ ok: true, action, started: { deployId: (started && started.id) || null }, render });
82
+ } catch (e) {
83
+ renderFailure(res, e, log);
84
+ }
85
+ });
86
+
87
+ return router;
88
+ };
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ // modules/render-deploy/routes/app.js — which Render service this hall manages (task 1004270).
4
+ //
5
+ // GET /render-deploy/app the connected workspace + service (two ids, nothing secret), or null
6
+ // PUT /render-deploy/app connect one: { ownerId, serviceId } with the owner's key in x-render-key
7
+ //
8
+ // GATED ON `core.pin.move`, the atom the npm-release deploy page and the hall's /deploy gate
9
+ // use: the owner's hard gate over what is running for users. An atom, not requireRank:
10
+ // tests/government_require_permission.mjs fails a new requireRank in a modules/*/routes gate.
11
+ //
12
+ // CONNECT NAMES THE WORKSPACE, IT NEVER PICKS IT. The owner types the workspace id; the key's
13
+ // own workspace list (Render's GET /owners) is used for one thing only, to check the workspace
14
+ // they named is one the key can act in. The service is then read through the client's ownerId
15
+ // guard, so a service in any other workspace is refused before a row is written.
16
+
17
+ const express = require('express');
18
+ const api = require('../../../src/module-api');
19
+ const { OWNER_RE, SERVICE_RE, getApp, saveApp } = require('../deploys');
20
+ const { defaultMakeClient, keyFrom, refusal, renderFailure, NEEDS_KEY } = require('../render');
21
+
22
+ const log = api.logger('render-deploy');
23
+
24
+ module.exports = function buildRenderDeployAppRouter({ makeClient = defaultMakeClient, pool } = {}) {
25
+ const router = express.Router();
26
+ const db = () => pool || api.pool;
27
+
28
+ router.get('/render-deploy/app', api.requireBuilder, api.requirePermission('core.pin.move'), async (req, res) => {
29
+ try {
30
+ const app = await getApp(db());
31
+ res.json({ app: app ? { ownerId: app.owner_id, serviceId: app.service_id, serviceName: app.service_name } : null });
32
+ } catch (e) {
33
+ log.error(`app read failed: ${e && e.message}`);
34
+ refusal(res, 500, 'app_read_failed', 'Could not read which Render service this project is connected to.');
35
+ }
36
+ });
37
+
38
+ router.put('/render-deploy/app', api.requireBuilder, api.requirePermission('core.pin.move'), async (req, res) => {
39
+ if (api.validateOrRespond(req, res, {
40
+ ownerId: { type: 'string', maxLength: 80 },
41
+ serviceId: { type: 'string', maxLength: 80 },
42
+ })) return;
43
+ const body = req.body || {};
44
+ const ownerId = typeof body.ownerId === 'string' ? body.ownerId.trim() : '';
45
+ const serviceId = typeof body.serviceId === 'string' ? body.serviceId.trim() : '';
46
+ if (!OWNER_RE.test(ownerId)) return refusal(res, 400, 'bad_owner_id', 'The workspace id looks like tea-… or usr-…. Copy it from Render.');
47
+ if (!SERVICE_RE.test(serviceId)) return refusal(res, 400, 'bad_service_id', 'The service id looks like srv-…. Copy it from the service page in Render.');
48
+ const key = keyFrom(req);
49
+ if (!key) return refusal(res, 400, 'render_key_needed', NEEDS_KEY);
50
+ try {
51
+ const client = makeClient({ key, ownerId });
52
+ const owners = await client.listOwners();
53
+ if (!owners.some((o) => o.id === ownerId)) {
54
+ return refusal(res, 403, 'workspace_not_reachable', 'That Render key cannot act in the workspace you named, so nothing was connected.');
55
+ }
56
+ const service = await client.getService(serviceId);
57
+ const saved = await saveApp(db(), { ownerId, serviceId, serviceName: service && service.name, builderId: req.builder && req.builder.id });
58
+ res.json({ app: { ownerId: saved.owner_id, serviceId: saved.service_id, serviceName: saved.service_name } });
59
+ } catch (e) {
60
+ renderFailure(res, e, log);
61
+ }
62
+ });
63
+
64
+ return router;
65
+ };
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ // modules/render-deploy/routes/history.js — GET /render-deploy/history (task 1004270).
4
+ //
5
+ // The service's Render deploys, each with its commit and the Bongos tasks in it, and which one
6
+ // is live. READ-ONLY: nothing here writes to Render or to the ledger. Gated on `core.pin.move`
7
+ // like the page it feeds (see routes/app.js), because it discloses what is running and on which
8
+ // plan. With no key it answers `needsKey` and the connected app rather than an error, so the page
9
+ // can draw the field that asks for one.
10
+
11
+ const express = require('express');
12
+ const api = require('../../../src/module-api');
13
+ const { getApp, readHistory } = require('../deploys');
14
+ const { defaultMakeClient, keyFrom, refusal, renderFailure } = require('../render');
15
+
16
+ const log = api.logger('render-deploy');
17
+
18
+ module.exports = function buildRenderDeployHistoryRouter({ makeClient = defaultMakeClient, pool } = {}) {
19
+ const router = express.Router();
20
+
21
+ router.get('/render-deploy/history', api.requireBuilder, api.requirePermission('core.pin.move'), async (req, res) => {
22
+ const db = pool || api.pool;
23
+ try {
24
+ const app = await getApp(db);
25
+ if (!app) return res.json({ connected: false });
26
+ const key = keyFrom(req);
27
+ if (!key) return res.json({ connected: true, needsKey: true, app: { ownerId: app.owner_id, serviceId: app.service_id, serviceName: app.service_name } });
28
+ const client = makeClient({ key, ownerId: app.owner_id });
29
+ res.json({ connected: true, needsKey: false, ...(await readHistory({ pool: db, client, app })) });
30
+ } catch (e) {
31
+ if (e && (e.name === 'RenderOwnerMismatch' || e.name === 'RenderApiError')) return renderFailure(res, e, log);
32
+ log.error(`history read failed: ${e && e.message}`);
33
+ refusal(res, 500, 'history_failed', 'Could not read the deploy history.');
34
+ }
35
+ });
36
+
37
+ return router;
38
+ };
@@ -18,19 +18,20 @@ const exec = (executor) => executor || ((text, params) => pool.query(text, param
18
18
  const COLUMNS = `id, slug, name, discipline, summary, contract_md,
19
19
  owner_kind, owner_builder_id, owner_project_id,
20
20
  visibility, sellable, offered, offered_by, offered_at,
21
- created_by, created_at, updated_at`;
21
+ skills, created_by, created_at, updated_at`;
22
22
 
23
23
  async function createSpeciality(row, executor) {
24
24
  const q = exec(executor);
25
25
  const r = await q(
26
26
  `INSERT INTO specialities_catalog
27
27
  (slug, name, discipline, summary, contract_md, owner_kind,
28
- owner_builder_id, owner_project_id, visibility, sellable, created_by)
29
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
28
+ owner_builder_id, owner_project_id, visibility, sellable, created_by, skills)
29
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
30
30
  RETURNING ${COLUMNS}`,
31
31
  [row.slug, row.name, row.discipline, row.summary || '', row.contract_md || '',
32
32
  row.owner_kind, row.owner_builder_id || null, row.owner_project_id || null,
33
- row.visibility || 'private', !!row.sellable, row.created_by || null],
33
+ row.visibility || 'private', !!row.sellable, row.created_by || null,
34
+ Array.isArray(row.skills) ? row.skills : []],
34
35
  );
35
36
  return r.rows[0] || null;
36
37
  }
@@ -61,7 +62,7 @@ async function listVisibleTo({ builderId, projectIds = [], discipline = null },
61
62
  // A speciality's DISCIPLINE IS IMMUTABLE and is not in this list on purpose:
62
63
  // moving one between crafts would silently re-scope everyone who adopted it, and
63
64
  // the adoption rows carry a denormalised copy the migration relies on.
64
- const UPDATABLE = ['name', 'summary', 'contract_md', 'visibility', 'sellable'];
65
+ const UPDATABLE = ['name', 'summary', 'contract_md', 'visibility', 'sellable', 'skills'];
65
66
 
66
67
  async function updateSpeciality(id, patch, executor) {
67
68
  const q = exec(executor);
@@ -174,12 +175,29 @@ async function adopt({ builderId, specialityId, discipline }, executor) {
174
175
  VALUES ($1,$2,$3,TRUE)
175
176
  ON CONFLICT (builder_id, speciality_id)
176
177
  DO UPDATE SET active = TRUE, discipline = EXCLUDED.discipline, adopted_at = now()
177
- RETURNING builder_id, speciality_id, discipline, active, adopted_at`,
178
+ RETURNING builder_id, speciality_id, discipline, active, adopted_at, enabled_skills`,
178
179
  [builderId, specialityId, discipline],
179
180
  );
180
181
  return r.rows[0] || null;
181
182
  }
182
183
 
184
+ // An adopter's ENABLED skills (task 1004072). Written only on the caller's own
185
+ // ACTIVE adoption: a skill is chosen for a speciality you hold, and a speciality
186
+ // you set down keeps its choices but cannot be edited until you take it up again.
187
+ // The route has already checked every name against the offered set and the
188
+ // instance (specialities.enabledSkillsErrors); this only stores the decision.
189
+ // Returns null when there is no active adoption to write to.
190
+ async function setEnabledSkills(builderId, specialityId, skills, executor) {
191
+ const q = exec(executor);
192
+ const r = await q(
193
+ `UPDATE specialities_adoptions SET enabled_skills = $3
194
+ WHERE builder_id = $1 AND speciality_id = $2 AND active
195
+ RETURNING builder_id, speciality_id, discipline, active, adopted_at, enabled_skills`,
196
+ [builderId, specialityId, skills],
197
+ );
198
+ return r.rows[0] || null;
199
+ }
200
+
183
201
  async function abandon(builderId, specialityId, executor) {
184
202
  const q = exec(executor);
185
203
  const r = await q(
@@ -209,7 +227,8 @@ async function listAdoptions(builderId, executor) {
209
227
  // contract_md is HERE because the session-injection port builds the injected
210
228
  // fragment from this row — omitting it silently dropped the expert's own terms
211
229
  // and left only the name and summary reaching the session.
212
- `SELECT s.id, s.slug, s.name, s.discipline, s.summary, s.contract_md, a.active, a.adopted_at
230
+ `SELECT s.id, s.slug, s.name, s.discipline, s.summary, s.contract_md, s.skills, a.active, a.adopted_at,
231
+ a.enabled_skills
213
232
  FROM specialities_adoptions a JOIN specialities_catalog s ON s.id = a.speciality_id
214
233
  WHERE a.builder_id = $1 ORDER BY a.adopted_at DESC`,
215
234
  [builderId],
@@ -269,5 +288,5 @@ module.exports = {
269
288
  createSpeciality, getSpeciality, listVisibleTo, updateSpeciality,
270
289
  addDocument, listDocuments, getDocument, deleteDocument,
271
290
  linkLearning, countTraining,
272
- adopt, abandon, deactivateCraft, activeForCraft, listAdoptions,
291
+ adopt, abandon, deactivateCraft, activeForCraft, listAdoptions, setEnabledSkills,
273
292
  };
@@ -0,0 +1,46 @@
1
+ -- specialities_003_skills.sql — a speciality OFFERS skills, and each adopter
2
+ -- turns them on for themselves (task 1004072, part 1 of 3 of ADR 0310).
3
+ --
4
+ -- TWO LAYERS, because one layer is the blanket the owner ruled against
5
+ -- (2026-09-19: "if say two people are economist ideator specialists, then they
6
+ -- decide which skills they want to use ... It does not become super blanketed"):
7
+ --
8
+ -- specialities_catalog.skills what this speciality OFFERS
9
+ -- specialities_adoptions.enabled_skills what THIS builder turned on
10
+ --
11
+ -- `skills`, not `offered_skills`: `offered` on the catalog already means "the
12
+ -- project recommends this speciality" (specialities_002), a different fact.
13
+ --
14
+ -- A NEW ADOPTION'S ENABLED SET IS EMPTY, AND THAT IS THE STORED DEFAULT, not a
15
+ -- UI default. The owner's second ruling: "They should be off, and you should
16
+ -- walk through what comes with each." A column default of '{}' makes that true
17
+ -- for every adoption however it is created, including one written by a script.
18
+ -- Existing adoptions get the same empty set, which is correct: nobody has been
19
+ -- walked through anything yet.
20
+ --
21
+ -- NAMING A SKILL GRANTS NOTHING (ADR 0310 §1). These are menus and choices; a
22
+ -- skill is still materialised and permitted by the machinery that always did.
23
+ -- Which names are real is checked where the instance's skills are known (the
24
+ -- route, modules/specialities/skills.js), not here: the set changes on deploy.
25
+ --
26
+ -- Bounded for the reason contract_md is: the enabled set is named in every
27
+ -- session that holds a claim in the craft (part 3), so it is a standing cost.
28
+ -- Additive and forward-safe: old code neither reads nor writes either column.
29
+
30
+ ALTER TABLE specialities_catalog
31
+ ADD COLUMN IF NOT EXISTS skills TEXT[] NOT NULL DEFAULT '{}';
32
+
33
+ ALTER TABLE specialities_catalog
34
+ DROP CONSTRAINT IF EXISTS specialities_catalog_skills_bounded;
35
+ ALTER TABLE specialities_catalog
36
+ ADD CONSTRAINT specialities_catalog_skills_bounded
37
+ CHECK (cardinality(skills) <= 24);
38
+
39
+ ALTER TABLE specialities_adoptions
40
+ ADD COLUMN IF NOT EXISTS enabled_skills TEXT[] NOT NULL DEFAULT '{}';
41
+
42
+ ALTER TABLE specialities_adoptions
43
+ DROP CONSTRAINT IF EXISTS specialities_adoptions_enabled_skills_bounded;
44
+ ALTER TABLE specialities_adoptions
45
+ ADD CONSTRAINT specialities_adoptions_enabled_skills_bounded
46
+ CHECK (cardinality(enabled_skills) <= 24);
@@ -25,6 +25,18 @@ const crypto = require('node:crypto');
25
25
  const api = require('../../../src/module-api');
26
26
  const S = require('../specialities');
27
27
  const db = require('../db');
28
+ const skillsLib = require('../skills');
29
+
30
+ // The skills this instance has (task 1004072), resolved through the doorway's
31
+ // roots and module predicate, never a core require. Cached briefly inside
32
+ // skills.js because the set only changes on deploy.
33
+ function installedSkills() {
34
+ return skillsLib.installedSkillsCached({
35
+ coreRoot: api.resolveCoreRoot(),
36
+ instanceRoot: api.resolveInstanceRoot(),
37
+ isModuleEnabled: (key) => api.isModuleEnabled(key),
38
+ });
39
+ }
28
40
 
29
41
  const { validateOrRespond, parseId, asyncHandler } = api;
30
42
 
@@ -160,6 +172,7 @@ module.exports = function buildSpecialitiesRouter() {
160
172
  contract_md: { type: 'string', maxLength: S.LIMITS.CONTRACT },
161
173
  visibility: { type: 'string', maxLength: 16 },
162
174
  sellable: { type: 'boolean' },
175
+ skills: { type: 'array', maxItems: S.LIMITS.SKILLS, itemsType: 'string' },
163
176
  })) return;
164
177
 
165
178
  const b = req.body || {};
@@ -173,12 +186,13 @@ module.exports = function buildSpecialitiesRouter() {
173
186
  contract_md: b.contract_md || '',
174
187
  visibility: b.visibility || 'private',
175
188
  sellable: !!b.sellable,
189
+ skills: Array.isArray(b.skills) ? b.skills : [],
176
190
  owner_kind: ownerKind,
177
191
  owner_builder_id: builder.id,
178
192
  owner_project_id: null,
179
193
  };
180
194
 
181
- const check = S.validateDraft(draft, { disciplines: api.enabledDisciplines() });
195
+ const check = S.validateDraft(draft, { disciplines: api.enabledDisciplines(), installedSkills: installedSkills() });
182
196
  if (!check.ok) return res.fail('validation_failed', { status: 400, details: { errors: check.errors } });
183
197
 
184
198
  draft.slug = S.slugify(draft.name);
@@ -205,6 +219,7 @@ module.exports = function buildSpecialitiesRouter() {
205
219
  contract_md: { type: 'string', maxLength: S.LIMITS.CONTRACT },
206
220
  visibility: { type: 'string', maxLength: 16 },
207
221
  sellable: { type: 'boolean' },
222
+ skills: { type: 'array', maxItems: S.LIMITS.SKILLS, itemsType: 'string' },
208
223
  })) return;
209
224
 
210
225
  const projectId = String(req.params.projectId);
@@ -226,12 +241,13 @@ module.exports = function buildSpecialitiesRouter() {
226
241
  contract_md: b.contract_md || '',
227
242
  visibility: b.visibility || 'private',
228
243
  sellable: !!b.sellable,
244
+ skills: Array.isArray(b.skills) ? b.skills : [],
229
245
  owner_kind: 'project',
230
246
  owner_builder_id: null,
231
247
  owner_project_id: projectId,
232
248
  };
233
249
 
234
- const check = S.validateDraft(draft, { disciplines: api.enabledDisciplines() });
250
+ const check = S.validateDraft(draft, { disciplines: api.enabledDisciplines(), installedSkills: installedSkills() });
235
251
  if (!check.ok) return res.fail('validation_failed', { status: 400, details: { errors: check.errors } });
236
252
 
237
253
  draft.slug = S.slugify(draft.name);
@@ -287,6 +303,7 @@ module.exports = function buildSpecialitiesRouter() {
287
303
  contract_md: { type: 'string', maxLength: S.LIMITS.CONTRACT },
288
304
  visibility: { type: 'string', maxLength: 16 },
289
305
  sellable: { type: 'boolean' },
306
+ skills: { type: 'array', maxItems: S.LIMITS.SKILLS, itemsType: 'string' },
290
307
  })) return;
291
308
 
292
309
  const s = await db.getSpeciality(id);
@@ -304,7 +321,11 @@ module.exports = function buildSpecialitiesRouter() {
304
321
  }
305
322
 
306
323
  const merged = { ...s, ...req.body };
307
- const check = S.validateDraft(merged, { disciplines: api.enabledDisciplines() });
324
+ // The offered set is re-checked only when this edit SENDS it (task 1004072):
325
+ // a skill removed from the instance since must not block renaming the
326
+ // speciality, while a new list is held to what exists now.
327
+ if (!req.body || req.body.skills === undefined) merged.skills = undefined;
328
+ const check = S.validateDraft(merged, { disciplines: api.enabledDisciplines(), installedSkills: installedSkills() });
308
329
  if (!check.ok) return res.fail('validation_failed', { status: 400, details: { errors: check.errors } });
309
330
 
310
331
  res.json({ speciality: await db.updateSpeciality(id, req.body || {}) });
@@ -380,6 +401,33 @@ module.exports = function buildSpecialitiesRouter() {
380
401
  res.json({ adoption });
381
402
  }));
382
403
 
404
+ // PUT /specialities/:id/skills — the caller's ENABLED set for a speciality they
405
+ // hold (task 1004072, ADR 0310 §2). Replaces the set whole: the walkthrough
406
+ // (task 1004073) sends the decisions it collected, so a partial patch would
407
+ // leave a skill switched on that the person was never shown again. Own
408
+ // adoption only, and only while active. Every name must be offered by the
409
+ // speciality AND exist here. rank: any builder, on their own row only.
410
+ router.put('/specialities/:id/skills', api.requireBuilder, asyncHandler('PUT /specialities/:id/skills', async (req, res) => {
411
+ const id = parseId(req, res, { code: 'bad_id' });
412
+ if (id === null) return;
413
+ if (validateOrRespond(req, res, {
414
+ enabled_skills: { required: true, type: 'array', maxItems: S.LIMITS.SKILLS, itemsType: 'string' },
415
+ })) return;
416
+ const s = await db.getSpeciality(id);
417
+ if (!s || !(await visibleTo(s, req.builder))) return res.fail('not_found', 404);
418
+ const requested = req.body.enabled_skills;
419
+ const errors = S.enabledSkillsErrors(requested, s, installedSkills());
420
+ if (errors.length) return res.fail('validation_failed', { status: 400, details: { errors } });
421
+ const adoption = await db.setEnabledSkills(req.builder.id, id, requested);
422
+ if (!adoption) {
423
+ return res.fail('not_adopted', {
424
+ status: 409,
425
+ message: 'Adopt this speciality first — skills are chosen for a speciality you hold.',
426
+ });
427
+ }
428
+ res.json({ adoption });
429
+ }));
430
+
383
431
  router.post('/specialities/:id/abandon', api.requireBuilder, asyncHandler('POST /specialities/:id/abandon', async (req, res) => {
384
432
  const id = parseId(req, res, { code: 'bad_id' });
385
433
  if (id === null) return;
@@ -0,0 +1,94 @@
1
+ // modules/specialities/skills.js — the skills THIS instance actually has, read
2
+ // from disk (task 1004072; ADR 0310 §"What this is NOT": "a speciality may name
3
+ // only skills the instance actually has").
4
+ //
5
+ // WHY FROM DISK. A skill is a directory holding a SKILL.md, in three places:
6
+ // the core's .claude/skills/, the instance's own .claude/skills/ (a split-root
7
+ // instance keeps its authored skills beside its config), and each ENABLED
8
+ // module's modules/<key>/skills/ (task 1003321, the tree claude-materialize
9
+ // lands into a checkout). Those three are exactly what a session on this
10
+ // instance can be handed, so they are the vocabulary a speciality may offer from.
11
+ // A disabled module's skills are not the instance's, so they are left out: a
12
+ // speciality offering one would walk a newcomer through a skill that 404s.
13
+ //
14
+ // NAMING A SKILL GRANTS NOTHING (ADR 0310 §1). This file only answers "does it
15
+ // exist"; nothing here materialises a skill or widens what anyone may run.
16
+ //
17
+ // The roots and the module predicate are INJECTED, so a test can hand it a temp
18
+ // tree and the route hands it the doorway's resolvers. Every read fails soft to
19
+ // "not here": an unreadable directory contributes no skills rather than
20
+ // throwing out of a request, and the validator treats an absent skill as
21
+ // unknown, which is the fail-closed reading.
22
+
23
+ 'use strict';
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+
28
+ // The same allowlist claude-materialize applies to a skill directory name:
29
+ // lowercase, digits, hyphen. A name outside it is never a skill here.
30
+ const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
31
+
32
+ function listDirs(dir) {
33
+ try {
34
+ return fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
35
+ } catch { return []; }
36
+ }
37
+
38
+ // The description line from a SKILL.md's frontmatter, for the adoption
39
+ // walkthrough (task 1004073) to show. Flat `description: ...` only, the grammar
40
+ // every SKILL.md here uses; anything else reads as no description.
41
+ function readDescription(skillMd) {
42
+ try {
43
+ const text = fs.readFileSync(skillMd, 'utf8');
44
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
45
+ if (!fm) return '';
46
+ const line = fm[1].split(/\r?\n/).find((l) => /^description:\s*/.test(l));
47
+ if (!line) return '';
48
+ return line.replace(/^description:\s*/, '').replace(/^(['"])([\s\S]*)\1$/, '$2').trim();
49
+ } catch { return ''; }
50
+ }
51
+
52
+ function collect(out, dir, source) {
53
+ for (const name of listDirs(dir)) {
54
+ if (!SKILL_NAME_RE.test(name) || out.has(name)) continue;
55
+ const md = path.join(dir, name, 'SKILL.md');
56
+ if (!fs.existsSync(md)) continue;
57
+ out.set(name, { name, description: readDescription(md), source });
58
+ }
59
+ }
60
+
61
+ /**
62
+ * The skills this instance has: Map name -> { name, description, source }.
63
+ * First root wins on a duplicate name (core, then instance, then modules), the
64
+ * precedence materialisation uses.
65
+ */
66
+ function installedSkills({ coreRoot, instanceRoot = null, isModuleEnabled = () => false } = {}) {
67
+ const out = new Map();
68
+ const roots = [...new Set([coreRoot, instanceRoot].filter(Boolean))];
69
+ for (const root of roots) collect(out, path.join(root, '.claude', 'skills'), root === coreRoot ? 'core' : 'instance');
70
+ for (const root of roots) {
71
+ const modulesDir = path.join(root, 'modules');
72
+ for (const key of listDirs(modulesDir)) {
73
+ let on = false;
74
+ try { on = isModuleEnabled(key) === true; } catch { on = false; }
75
+ if (on) collect(out, path.join(modulesDir, key, 'skills'), `module:${key}`);
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+
81
+ // A short-lived cache, because the set changes only on deploy and every
82
+ // authoring write and skills read asks for it. 60s bounds how long a skill that
83
+ // was just removed can still be offered.
84
+ let cached = null;
85
+ const TTL_MS = 60 * 1000;
86
+ function installedSkillsCached(opts, now = Date.now()) {
87
+ if (cached && now - cached.at < TTL_MS) return cached.map;
88
+ const map = installedSkills(opts);
89
+ cached = { at: now, map };
90
+ return map;
91
+ }
92
+ function _resetCache() { cached = null; }
93
+
94
+ module.exports = { SKILL_NAME_RE, installedSkills, installedSkillsCached, readDescription, _resetCache };
@@ -1,5 +1,5 @@
1
1
  // modules/specialities/specialities.js — role SUB-SPECIALITIES: a named bundle of
2
- // knowledge, skills (ADR 0310 — decided, NOT yet built) and an interaction contract that sits UNDER a craft
2
+ // knowledge, skills (ADR 0310 — offered and enabled sets, task 1004072) and an interaction contract that sits UNDER a craft
3
3
  // (task 1003855, goal 1000095; ADR 0296).
4
4
  //
5
5
  // WHAT THIS IS FOR. A craft says how you work (engineer / ideator / artist / ui).
@@ -14,12 +14,13 @@
14
14
  // resolved into GET /me and injected by the
15
15
  // Conductor hook — a speciality contract rides
16
16
  // that SAME path, it does not add a second one
17
- // • declared skills NOT BUILT. Decided in ADR 0310 (task 1003978)
18
- // and deliberately not implemented there: a speciality
19
- // will OFFER skills, and each adopter enables them one
20
- // at a time after being shown what each does. Nothing
21
- // in this file, the schema or the routes carries it yet —
22
- // `grep -i skill` here finds only prose.
17
+ // • declared skills ADR 0310, part 1 built in task 1004072: a speciality
18
+ // OFFERS skills (`skills`), each adopter's ENABLED set
19
+ // starts empty (`enabled_skills`), and only a skill this
20
+ // instance has may be named (skills.js). The walkthrough
21
+ // that fills the enabled set is task 1004073; the session
22
+ // line naming it is task 1004074. Naming a skill grants
23
+ // nothing.
23
24
  // The genuinely new part is the knowledge bundle, which is why the migration adds
24
25
  // tables for it and this file carries the rules over them.
25
26
  //
@@ -45,8 +46,50 @@ const LIMITS = {
45
46
  DOC_TITLE: 200,
46
47
  DOC_BODY: 100000,
47
48
  DOCS_PER_SPECIALITY: 200,
49
+ // Mirrors the cardinality CHECKs in specialities_003. A standing cost: the
50
+ // enabled set is named in every session holding a claim in the craft.
51
+ SKILLS: 24,
48
52
  };
49
53
 
54
+ const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
55
+
56
+ // The shape and existence rules for a list of skill names, shared by the
57
+ // offered set (validateDraft) and an adopter's enabled set (enabledSkillsErrors).
58
+ // `installed` is the set of names this instance has (skills.js), passed IN so
59
+ // the rule stays pure. It is REQUIRED whenever the list is non-empty: a list
60
+ // nothing checked is refused, never admitted (fail-closed, ADR 0310 "a speciality
61
+ // may name only skills the instance actually has").
62
+ function skillListErrors(list, field, installed) {
63
+ const errors = [];
64
+ if (list == null) return errors;
65
+ if (!Array.isArray(list)) { errors.push({ field, code: 'invalid', expected: 'array of skill names' }); return errors; }
66
+ if (list.length > LIMITS.SKILLS) errors.push({ field, code: 'too_many', max: LIMITS.SKILLS });
67
+ const bad = list.filter((n) => typeof n !== 'string' || !SKILL_NAME_RE.test(n));
68
+ if (bad.length) errors.push({ field, code: 'invalid_name', names: bad.map(String) });
69
+ const seen = new Set(); const dup = [];
70
+ for (const n of list) { if (seen.has(n)) dup.push(n); seen.add(n); }
71
+ if (dup.length) errors.push({ field, code: 'duplicate', names: [...new Set(dup)] });
72
+ if (list.length && !(installed instanceof Set || installed instanceof Map)) {
73
+ errors.push({ field, code: 'uncheckable' });
74
+ } else if (list.length) {
75
+ const unknown = list.filter((n) => typeof n === 'string' && SKILL_NAME_RE.test(n) && !installed.has(n));
76
+ if (unknown.length) errors.push({ field, code: 'unknown_skill', names: unknown });
77
+ }
78
+ return errors;
79
+ }
80
+
81
+ // An adopter's ENABLED set: every name must be offered by the speciality AND
82
+ // exist on this instance. Offered-but-since-removed is refused too — enabling a
83
+ // skill that 404s is the dead end ADR 0310 names.
84
+ function enabledSkillsErrors(requested, speciality, installed) {
85
+ const errors = skillListErrors(requested, 'enabled_skills', installed);
86
+ if (!Array.isArray(requested)) return errors;
87
+ const offered = new Set((speciality && Array.isArray(speciality.skills)) ? speciality.skills : []);
88
+ const notOffered = requested.filter((n) => typeof n === 'string' && !offered.has(n));
89
+ if (notOffered.length) errors.push({ field: 'enabled_skills', code: 'not_offered', names: notOffered });
90
+ return errors;
91
+ }
92
+
50
93
  // A slug is derived, never asked for: the authoring surface takes a human name
51
94
  // and this makes it addressable. Unique PER OWNER, so two experts may both own
52
95
  // an "economics" — see the migration's partial indexes.
@@ -64,7 +107,7 @@ function slugify(name) {
64
107
  // discipline-modes.json plus whatever enabled modules contribute
65
108
  // (enabledDisciplines()), so it is a runtime value and hardcoding it here would
66
109
  // silently reject a module-contributed craft like `ui`.
67
- function validateDraft(draft, { disciplines } = {}) {
110
+ function validateDraft(draft, { disciplines, installedSkills = null } = {}) {
68
111
  const errors = [];
69
112
  const d = draft || {};
70
113
  const name = String(d.name || '').trim();
@@ -112,6 +155,10 @@ function validateDraft(draft, { disciplines } = {}) {
112
155
  errors.push({ field: 'visibility', code: 'project_needs_project_owner' });
113
156
  }
114
157
 
158
+ // The skills this speciality OFFERS (task 1004072). Only names this instance
159
+ // has; checked here so authoring gets a field-level refusal.
160
+ errors.push(...skillListErrors(d.skills, 'skills', installedSkills));
161
+
115
162
  const ownerKind = d.owner_kind;
116
163
  if (ownerKind !== 'builder' && ownerKind !== 'project') {
117
164
  errors.push({ field: 'owner_kind', code: 'invalid', allowed: ['builder', 'project'] });
@@ -229,4 +276,4 @@ function offeredTo(specialities, viewer, adoptions = []) {
229
276
  });
230
277
  }
231
278
 
232
- module.exports = { VISIBILITIES, LIMITS, slugify, validateDraft, canAdopt, describeForApi, offeredTo };
279
+ module.exports = { VISIBILITIES, LIMITS, SKILL_NAME_RE, slugify, validateDraft, skillListErrors, enabledSkillsErrors, canAdopt, describeForApi, offeredTo };
@@ -0,0 +1,12 @@
1
+ {
2
+ "hide_stats": true,
3
+ "profile_state": "provisional",
4
+ "account_visibility": "private",
5
+ "recruiter_discoverable": null,
6
+ "recruiter_discoverable_effective": false,
7
+ "handle": "builder",
8
+ "display_name": null,
9
+ "bio": null,
10
+ "links": null,
11
+ "cross_project": null
12
+ }