@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.
- package/.bongos-core.json +526 -191
- package/.claude/skills/planning-session/SKILL.md +6 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +32 -0
- package/clients/bongos-client/index.cjs +32 -0
- package/clients/bongos-client/index.d.ts +51 -6
- package/clients/bongos-client/index.mjs +32 -0
- package/docs/adr/0099-delayed-redacted-mirror-export.md +5 -1
- package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
- package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +17 -0
- package/docs/adr/0176-private-repo-deploy-keys.md +2 -0
- package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
- package/docs/adr/0348-the-web-tier-may-look-read-only-at-what-an-owners-render-key-can-see.md +53 -0
- package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +106 -0
- package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +103 -0
- package/docs/adr/README.md +3 -0
- package/docs/api/openapi.json +851 -19
- package/docs/api-reference.md +33 -9
- package/docs/architecture.md +6 -2
- package/docs/copy-inventory.md +620 -553
- package/docs/copy-registry.json +1467 -820
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +6 -0
- package/docs/modules-contract.md +1 -0
- package/docs/page-inventory.json +38 -4
- package/docs/page-readings.json +1674 -1566
- package/migrations/core_260_goals_working_area.sql +63 -0
- package/migrations/core_261_grade_attempts_verdicts.sql +24 -0
- package/migrations/core_262_drop_builder_box_blocked.sql +33 -0
- package/modules/agents/lib/validate.js +43 -0
- package/modules/autonomy/gauge.js +38 -2
- package/modules/grading/grader-subagent.js +37 -2
- package/modules/grading/grader-workers/reader.js +65 -5
- package/modules/hall-ui/public/approval-queue.css +33 -7
- package/modules/hall-ui/public/approval-queue.js +9 -2
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/brand-holes.js +121 -0
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/collab.js +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +6 -1
- package/modules/hall-ui/public/deploy.js +22 -8
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/dom-utils.js +20 -0
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/idea.js +28 -0
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/ideas.js +19 -3
- package/modules/hall-ui/public/index.html +5 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile-nudge.js +21 -4
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/profile.js +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +19 -30
- package/modules/hall-ui/public/settings.js +24 -177
- package/modules/hall-ui/public/settings.states.json +1 -1
- package/modules/hall-ui/public/shell.js +4 -2
- package/modules/hall-ui/public/studio.css +14 -8
- package/modules/hall-ui/public/studio.html +5 -4
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.css +6 -3
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor-lib.js +82 -1
- package/modules/hall-ui/public/tweak-editor.css +142 -11
- package/modules/hall-ui/public/tweak-editor.html +25 -9
- package/modules/hall-ui/public/tweak-editor.js +243 -10
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/approval-queue.md +6 -0
- package/modules/hall-ui/records/tweak-editor.md +6 -0
- package/modules/ideas/ratify.js +36 -8
- package/modules/ideas/routes/ratify-goal.js +6 -0
- package/modules/lifecycle/db-analytics.js +79 -10
- package/modules/lifecycle/db-claims.js +48 -1
- package/modules/lifecycle/db-deps-criteria.js +94 -40
- package/modules/lifecycle/db-goals.js +4 -1
- package/modules/lifecycle/db-grade.js +9 -1
- package/modules/lifecycle/db-relevance-flags.js +183 -0
- package/modules/lifecycle/db-versions.js +4 -3
- package/modules/lifecycle/db.js +19 -0
- package/modules/lifecycle/done-when.js +5 -3
- package/modules/lifecycle/est-advisory.js +31 -4
- package/modules/lifecycle/goal-task-relevance-advisory.js +153 -0
- package/modules/lifecycle/goal-task-relevance-judge.js +226 -0
- package/modules/lifecycle/migrations/lifecycle_014_goal_task_relevance_flags.sql +82 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/claims.js +8 -1
- package/modules/lifecycle/routes/goal-task-relevance.js +72 -0
- package/modules/lifecycle/routes/goals.js +1 -1
- package/modules/lifecycle/routes/tasks.js +27 -24
- package/modules/lifecycle/routes/visuals.js +9 -0
- package/modules/lifecycle/ship-preflight.js +7 -11
- package/modules/lifecycle/task-visuals.js +109 -5
- package/modules/npm-release/module.json +2 -1
- package/modules/npm-release/preview/commands.js +81 -0
- package/modules/npm-release/preview/divert.js +95 -0
- package/modules/npm-release/preview/env.js +78 -0
- package/modules/npm-release/preview/proxy.js +107 -0
- package/modules/npm-release/preview/runtime.js +83 -0
- package/modules/npm-release/preview/supervisor.js +175 -0
- package/modules/npm-release/public/work.js +65 -1
- package/modules/npm-release/routes/preview.js +133 -0
- package/modules/provisioning/app-status.js +83 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/pollers/app-liveness.js +110 -0
- package/modules/provisioning/render-lookup.js +68 -0
- package/modules/provisioning/render-standup.js +44 -1
- package/modules/provisioning/routes/render-standup.js +81 -6
- package/modules/provisioning/starter-bundles.js +4 -1
- package/modules/public-landing/public/account-private.states.json +14 -0
- package/modules/public-landing/public/account.html +513 -0
- package/modules/public-landing/public/account.probes.json +37 -0
- package/modules/public-landing/public/account.states.json +16 -0
- package/modules/public-landing/public/index.html +5 -2
- package/modules/public-landing/public/projects.html +661 -18
- package/modules/public-landing/public/projects.states.json +8 -1
- package/modules/render-deploy/deploys.js +203 -0
- package/modules/render-deploy/migrations/render_deploy_001_app.sql +22 -0
- package/modules/render-deploy/module.json +25 -0
- package/modules/render-deploy/public/deploy.css +10 -0
- package/modules/render-deploy/public/deploy.js +249 -0
- package/modules/render-deploy/render.js +56 -0
- package/modules/render-deploy/routes/act.js +88 -0
- package/modules/render-deploy/routes/app.js +65 -0
- package/modules/render-deploy/routes/history.js +38 -0
- package/modules/specialities/db.js +27 -8
- package/modules/specialities/migrations/specialities_003_skills.sql +46 -0
- package/modules/specialities/routes/specialities.js +51 -3
- package/modules/specialities/skills.js +94 -0
- package/modules/specialities/specialities.js +56 -9
- package/modules/ui-design/kit/fixtures/me-cross-project-private.json +12 -0
- package/modules/ui-design/kit/fixtures/me__cross-project.json +15 -0
- package/modules/ui-design/kit/fixtures/provisioning-instance-render.json +39 -0
- package/modules/ui-design/kit/lib.js +3 -1
- package/modules/ui-design/kit/serve.js +96 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +163 -0
- package/scripts/gds/agents-sync.js +13 -3
- package/scripts/gds/autobongos-run.js +80 -2
- package/scripts/gds/autobongos-service.cmd +12 -0
- package/scripts/gds/copy-apply.js +14 -0
- package/scripts/gds/dev-box-guard.js +3 -2
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/grade-correlation-audit.js +42 -12
- package/scripts/gds/grade-replay.js +1 -1
- package/scripts/gds/provision-core-upgrade.js +38 -7
- package/scripts/gds/provision-pin-key.js +176 -0
- package/scripts/gds/provision-render.js +62 -31
- package/scripts/gds/provision-teardown.js +7 -2
- package/scripts/gds/provision-units.js +31 -0
- package/scripts/gds/provision.js +10 -10
- package/scripts/gds/publish-manifest.js +1 -0
- package/scripts/gds/render-api.js +19 -0
- package/scripts/gds/render-payload.js +39 -65
- package/scripts/gds/run-unit-tests.js +10 -0
- package/scripts/gds/ship-finish.js +19 -20
- package/scripts/gds/smoke-dependencies.sh +37 -8
- package/scripts/gds/status.js +12 -4
- package/scripts/gds/upgrade.js +2 -2
- package/scripts/public-mirror-export.js +7 -1
- package/src/bongos/module-scope-map.js +13 -0
- package/src/bongos/serve-internal.js +3 -1
- package/src/module-api.js +10 -1
- package/src/platform-server.js +39 -0
- package/tests/account_privacy_flags.mjs +10 -6
- package/tests/agents_authoring.mjs +15 -1
- package/tests/agents_sync.mjs +106 -5
- package/tests/agents_validate.mjs +45 -0
- package/tests/api_path_404.mjs +6 -0
- package/tests/autobongos_cadence.mjs +7 -0
- package/tests/autobongos_loop.mjs +136 -1
- package/tests/autonomy_gauge.mjs +62 -0
- package/tests/blocker_hall_live_proof.mjs +275 -0
- package/tests/claim_gate_ci_unblock.mjs +116 -0
- package/tests/claim_gate_rebase.mjs +15 -3
- package/tests/collab_page.mjs +10 -0
- package/tests/conductor_main_e2e.mjs +97 -0
- package/tests/core_262_drop_box_blocked_db.mjs +143 -0
- package/tests/core_upgrade_runner.mjs +1 -0
- package/tests/dependency_writes_atomic.mjs +191 -0
- package/tests/deploy_page_projects.mjs +2 -1
- package/tests/effective_visibility_predicate.mjs +15 -0
- package/tests/est_advisory.mjs +27 -0
- package/tests/goal_map_page.mjs +26 -0
- package/tests/goal_task_relevance.mjs +518 -0
- package/tests/goal_working_area.mjs +146 -0
- package/tests/grade_attempts.mjs +61 -0
- package/tests/grade_attribution.mjs +23 -0
- package/tests/grade_correlation_audit.mjs +54 -1
- package/tests/grader_reader_lens.mjs +55 -2
- package/tests/grader_root_outage.mjs +6 -1
- package/tests/grader_subagent_tools_arg.mjs +102 -0
- package/tests/hall_approval_queue.mjs +54 -2
- package/tests/hall_settings_world.mjs +3 -1
- package/tests/hall_tweak_editor.mjs +316 -11
- package/tests/hub_account_page.mjs +394 -0
- package/tests/idea_detail_page.mjs +64 -0
- package/tests/idea_goal_ratification.mjs +64 -0
- package/tests/idea_spark_hall.mjs +60 -9
- package/tests/ideator_full_idea_shapes_space_proof.mjs +1 -1
- package/tests/landing_page.mjs +6 -3
- package/tests/module-scope-map.mjs +31 -8
- package/tests/module_api.mjs +1 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/no_phantom_mirror_workflow.mjs +24 -0
- package/tests/npm_release_preview_commands.mjs +89 -0
- package/tests/npm_release_preview_divert.mjs +164 -0
- package/tests/npm_release_preview_env.mjs +100 -0
- package/tests/npm_release_preview_proxy.mjs +162 -0
- package/tests/npm_release_preview_routes.mjs +208 -0
- package/tests/npm_release_preview_supervisor.mjs +203 -0
- package/tests/pin_write_key.mjs +383 -0
- package/tests/planning_session_skill.mjs +27 -0
- package/tests/platform_boot.mjs +71 -0
- package/tests/profile_nudge_links.mjs +86 -0
- package/tests/profile_ui_cross_project.mjs +1 -1
- package/tests/projects_hub.mjs +2 -2
- package/tests/projects_hub_app_status.mjs +249 -0
- package/tests/projects_hub_app_step.mjs +282 -52
- package/tests/projects_hub_render_connect.mjs +295 -0
- package/tests/provision_render.mjs +121 -28
- package/tests/provision_settings_apply.mjs +62 -0
- package/tests/provisioning_app_status.mjs +204 -0
- package/tests/provisioning_render_route.mjs +126 -6
- package/tests/render_check.mjs +2 -1
- package/tests/render_deploy.mjs +495 -0
- package/tests/ship_preflight.mjs +61 -8
- package/tests/smoke_dependencies_witness.mjs +46 -0
- package/tests/speciality_skills.mjs +214 -0
- package/tests/studio_room_height.mjs +70 -0
- package/tests/task_detail_includes.mjs +10 -0
- package/tests/task_visuals_instance_root.mjs +150 -0
- package/tests/tweak_batch_apply.mjs +21 -0
- package/tests/ui_design_kit.mjs +36 -4
- package/tests/upgrade.mjs +1 -1
- 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
|
-
|
|
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 —
|
|
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
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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
|
+
}
|