@bongos/core 1.20.2 → 1.20.3

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.
@@ -202,7 +202,9 @@ select.voices__select { min-width: 140px; }
202
202
  /* The adopted contract, shown in full before "Use this" — another builder's prose
203
203
  reaching your sessions is the one thing here that must never be folded away. */
204
204
  .speciality-contract { flex-basis: 100%; margin-top: 0.5rem; font-size: 0.85rem; }
205
- .speciality-contract > summary { cursor: pointer; opacity: 0.85; }
205
+ /* 24px tall: the panel was never revealed until task 1004073, so the kit had
206
+ never measured this toggle; it came back 21px. */
207
+ .speciality-contract > summary { cursor: pointer; opacity: 0.85; min-height: 24px; padding-block: 2px; box-sizing: border-box; }
206
208
  .speciality-contract__body {
207
209
  white-space: pre-wrap;
208
210
  margin: 0.4rem 0 0;
@@ -212,6 +214,55 @@ select.voices__select { min-width: 140px; }
212
214
  font-family: inherit;
213
215
  opacity: 0.92;
214
216
  }
217
+ /* The skills walkthrough (task 1004073, ADR 0310 §3): one offered skill at a
218
+ time, three answers as a definition list, then a decision. It sits inside the
219
+ card like the train form, so it inherits the card's ground in both modes. */
220
+ .skill-walk {
221
+ flex-basis: 100%;
222
+ margin-top: 0.75rem;
223
+ padding-top: 0.75rem;
224
+ border-top: 1px solid var(--rule);
225
+ display: flex;
226
+ flex-direction: column;
227
+ gap: 0.6rem;
228
+ }
229
+ /* The measure caps the reading lines, never the section: a capped flex-basis
230
+ lets the section fit beside the card's actions instead of wrapping under them. */
231
+ .skill-walk > * { max-width: var(--measure); }
232
+ .skill-walk__progress { margin: 0; font-size: 0.8rem; color: var(--ink-soft); }
233
+ .skill-walk__name { margin: 0; font-family: var(--font-mono); font-size: 1rem; color: var(--ink); }
234
+ .skill-walk__facts { margin: 0; display: grid; gap: 0.2rem 0; }
235
+ .skill-walk__facts dt { font-size: 0.8rem; color: var(--ink-soft); margin-top: 0.4rem; }
236
+ .skill-walk__facts dt:first-child { margin-top: 0; }
237
+ .skill-walk__facts dd { margin: 0; color: var(--ink); line-height: 1.5; }
238
+ .skill-walk__note { font-size: 0.8rem; }
239
+ /* "Leave it off" and "Turn it on" are the panel family's plain pill at EQUAL
240
+ weight: the default is off, so the page must not lean on the reader toward on.
241
+ Only "Save my choices" takes the accent. A decision already made reads through
242
+ aria-pressed when the person steps Back to it. */
243
+ .skill-walk__actions { flex-wrap: wrap; }
244
+ .skill-walk__actions .pbtn[aria-pressed="true"] { border-color: var(--ink-soft); }
245
+ .skill-walk__recap { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; }
246
+ .skill-walk__recap li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 0.4rem; }
247
+ .skill-walk__recap .prow__hint { flex-basis: 100%; margin: 0; }
248
+ .skill-walk__recap strong { font-family: var(--font-mono); font-weight: 600; }
249
+ /* "Finish later" is a way out, not a choice about a skill, so it reads as a quiet
250
+ link below the decision rather than a third button beside it. */
251
+ .skill-walk__later {
252
+ align-self: flex-start;
253
+ background: none;
254
+ border: 0;
255
+ padding: 0 0.25rem;
256
+ margin-inline-start: -0.25rem;
257
+ min-height: 24px;
258
+ font: inherit;
259
+ font-size: 0.8rem;
260
+ color: var(--ink-soft);
261
+ text-decoration: underline;
262
+ text-underline-offset: 3px;
263
+ cursor: pointer;
264
+ }
265
+ .skill-walk__later:hover { color: var(--ink); }
215
266
 
216
267
  /* ---- Software update (task 1004296) -------------------------------------- */
217
268
  /* A version is read digit by digit and compared against another, so it takes the mono
@@ -9,7 +9,8 @@
9
9
  "states": {
10
10
  "account": { "auth": true, "actions": [["wait", 900]], "expect": { "visible": ["#settings-h1", "#display-name-input", "#craft-chips"] } },
11
11
  "access": { "auth": true, "url": "/settings#access", "actions": [["wait", 900]], "expect": { "visible": ["#cli-reissue-btn", "#sessions-list", "#artkey-card"] } },
12
- "preferences": { "auth": true, "url": "/settings#preferences", "actions": [["wait", 900]], "expect": { "visible": ["#wander-rows", "#render-rows"] } },
12
+ "preferences": { "auth": true, "url": "/settings#preferences", "actions": [["wait", 900]], "expect": { "visible": ["#wander-rows", "#render-rows", "#specialities-rows"] } },
13
+ "skills-walkthrough": { "auth": true, "url": "/settings#preferences", "actions": [["wait", 900], ["click", "#specialities-rows button[data-act=\"adopt\"]"], ["wait", 700]], "expect": { "visible": [".skill-walk", ".skill-walk__facts", "button[data-walk=\"on\"]", "button[data-walk=\"off\"]"] } },
13
14
  "sound": { "auth": true, "url": "/settings#sound", "actions": [["wait", 900]], "expect": { "visible": ["#voices-table", "#sound-rows", "#event-sound-rows"] } },
14
15
  "update": { "auth": true, "url": "/settings#software-update", "actions": [["wait", 1200]], "expect": { "visible": ["#software-update-h", "#su-body", "#su-update", ".su-versions"] } }
15
16
  },
@@ -26,3 +26,5 @@ Also fixed here, as the board task handed forward: the two sub-24px links on Hom
26
26
  - **"Up to date" is said only from an answer.** A failed registry read, an unnamed running version and unread release notes each render as what they are; unread notes are never drawn as a version with no changes.
27
27
  - **The notes are the soft ink, underlined, on the platform.** There every entry links to its task, and a list of accent-coloured sentences read as a warning. Elsewhere the ids belong to another project's ledger, so the entries are plain text.
28
28
  - **Kit:** `settings.states.json` `update`, ALL CLEAN at 1440/390/320 × dark/light against the harness with the fixture `software-update.json` (a live reading for 1.19.1033). Tests: `tests/software_update.mjs`.
29
+
30
+ **Settings: adopting a speciality walks through its skills (task 1004073, ADR 0310 §3)** — the owner ruled an adopted speciality's skills start OFF "and you should walk through what comes with each". So adopting one that offers skills opens `.skill-walk` inside its card: one skill per step (what it does, when to reach for it, what it costs, what it needs, who can run it), "Leave it off" / "Turn it on" at EQUAL weight (plain `.pbtn`, no accent — the default is off, so the page must not lean toward on), Back, then a recap restating each skill in a line, then ONE `PUT` of the whole set. "Finish later" saves nothing. Focus moves to each new step heading. Two things found on the way: the specialities panel shipped `hidden` and nothing ever revealed it, so adoption was unreachable from the hall; and its contract `<summary>` measured 21px once visible (now 24). **Kit:** `settings.states.json` `skills-walkthrough` + `preferences`, ALL CLEAN at 1440/390/320 × dark/light against the harness, with fixtures `specialities*.json` built from the real skills on disk and canned answers for adopt and save. Tests: `tests/speciality_walkthrough_ui.mjs` (runs the real page script).
@@ -401,6 +401,33 @@ module.exports = function buildSpecialitiesRouter() {
401
401
  res.json({ adoption });
402
402
  }));
403
403
 
404
+ // GET /specialities/:id/skills — what the adoption WALKTHROUGH shows (task
405
+ // 1004073, ADR 0310 §3): each skill the speciality offers, explained for the
406
+ // person deciding (what it does, when to reach for it, what it costs, what it
407
+ // needs), plus the caller's own enabled set and whether they hold it at all.
408
+ // Same visibility as GET /specialities/:id — 404 for a row you cannot see. An
409
+ // offered skill this instance no longer has comes back `installed: false`, so
410
+ // the walkthrough can say so instead of offering something that would 404.
411
+ // Read-only; rank: any builder who may see the speciality.
412
+ router.get('/specialities/:id/skills', api.requireBuilder, asyncHandler('GET /specialities/:id/skills', async (req, res) => {
413
+ const id = parseId(req, res, { code: 'bad_id' });
414
+ if (id === null) return;
415
+ const s = await db.getSpeciality(id);
416
+ if (!s || !(await visibleTo(s, req.builder))) return res.fail('not_found', 404);
417
+ const installed = installedSkills();
418
+ const offered = Array.isArray(s.skills) ? s.skills : [];
419
+ const skills = offered.map((name) => (installed.has(name)
420
+ ? { ...skillsLib.explainSkill({ name, ...installed.get(name) }), installed: true }
421
+ : { name, installed: false }));
422
+ const mine = (await db.listAdoptions(req.builder.id)).find((a) => String(a.id) === String(id) && a.active);
423
+ res.json({
424
+ speciality: { id: s.id, name: s.name, discipline: s.discipline },
425
+ adopted: !!mine,
426
+ enabled: mine && Array.isArray(mine.enabled_skills) ? mine.enabled_skills : [],
427
+ skills,
428
+ });
429
+ }));
430
+
404
431
  // PUT /specialities/:id/skills — the caller's ENABLED set for a speciality they
405
432
  // hold (task 1004072, ADR 0310 §2). Replaces the set whole: the walkthrough
406
433
  // (task 1004073) sends the decisions it collected, so a partial patch would
@@ -35,29 +35,125 @@ function listDirs(dir) {
35
35
  } catch { return []; }
36
36
  }
37
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.
38
+ // The top-level keys of a SKILL.md's frontmatter, as strings (a flow list
39
+ // `[a, b]` as an array). Enough YAML for the grammar SKILL.md files use: a flat
40
+ // `key: value`, a quoted value, and a folded or literal block (`>-`, `>`, `|`,
41
+ // `|-`) whose indented lines follow. Part 1 read only the flat form, and almost
42
+ // every skill here folds its description, so each read as `>-` (task 1004073).
43
+ // Anything it cannot read is simply absent.
44
+ function parseFrontmatter(text) {
45
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text || ''));
46
+ if (!fm) return {};
47
+ const lines = fm[1].split(/\r?\n/);
48
+ const out = {};
49
+ for (let i = 0; i < lines.length; i++) {
50
+ const m = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(lines[i]);
51
+ if (!m) continue;
52
+ const [, key, rest] = m;
53
+ const block = /^([>|])([+-]?)\s*$/.exec(rest);
54
+ if (block) {
55
+ const body = [];
56
+ while (i + 1 < lines.length && (/^\s+\S/.test(lines[i + 1]) || lines[i + 1].trim() === '')) body.push(lines[++i].trim());
57
+ out[key] = block[1] === '>'
58
+ ? body.join('\n').split(/\n{2,}/).map((p) => p.split('\n').join(' ')).join('\n').trim()
59
+ : body.join('\n').trim();
60
+ continue;
61
+ }
62
+ const list = /^\[(.*)\]$/.exec(rest.trim());
63
+ if (list) { out[key] = list[1].split(',').map((s) => s.trim().replace(/^(['"])(.*)\1$/, '$2')).filter(Boolean); continue; }
64
+ out[key] = rest.trim().replace(/^(['"])([\s\S]*)\1$/, '$2');
65
+ }
66
+ return out;
67
+ }
68
+
69
+ function readFrontmatter(skillMd) {
70
+ try { return parseFrontmatter(fs.readFileSync(skillMd, 'utf8')); } catch { return {}; }
71
+ }
72
+
73
+ // The description a SKILL.md gives, for the adoption walkthrough (task 1004073).
41
74
  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 ''; }
75
+ const d = readFrontmatter(skillMd).description;
76
+ return typeof d === 'string' ? d : '';
50
77
  }
51
78
 
79
+ // The optional PEOPLE-FACING fields (task 1004073). `description` is written to
80
+ // be MATCHED by a model, so a skill's author may also say, for the person
81
+ // deciding whether to turn it on: what it does (`plain`), when to reach for it
82
+ // (`reach-for`) and what it costs (`cost`). Claude Code ignores keys it does not
83
+ // know, as it already does `requires:`.
84
+ const PEOPLE_KEYS = { plain: 'plain', 'reach-for': 'reachFor', cost: 'cost' };
85
+
52
86
  function collect(out, dir, source) {
53
87
  for (const name of listDirs(dir)) {
54
88
  if (!SKILL_NAME_RE.test(name) || out.has(name)) continue;
55
89
  const md = path.join(dir, name, 'SKILL.md');
56
90
  if (!fs.existsSync(md)) continue;
57
- out.set(name, { name, description: readDescription(md), source });
91
+ const fm = readFrontmatter(md);
92
+ const entry = { name, description: typeof fm.description === 'string' ? fm.description : '', source };
93
+ for (const [key, field] of Object.entries(PEOPLE_KEYS)) if (typeof fm[key] === 'string' && fm[key]) entry[field] = fm[key];
94
+ if (Array.isArray(fm.requires) && fm.requires.length) entry.requires = fm.requires;
95
+ out.set(name, entry);
58
96
  }
59
97
  }
60
98
 
99
+ // ONE SKILL, AS A PERSON DECIDING ABOUT IT READS IT (task 1004073; ADR 0310 §3:
100
+ // "what it does, when its author reaches for it, what it costs"). Pure: takes an
101
+ // installedSkills() entry and returns the three answers plus what the skill
102
+ // needs. An author's own people-facing field always wins; without one each
103
+ // answer is derived from the model-facing description, and `derived` says so,
104
+ // so the walkthrough can be honest that the words were not written for them.
105
+ //
106
+ // The rank note is kept apart on purpose: enabling a skill GRANTS NOTHING
107
+ // (ADR 0310 §1), so a Metic-only skill stays Metic-only after you turn it on,
108
+ // and the person should be told that before they choose it, not after.
109
+ function explainSkill(entry) {
110
+ const e = entry || {};
111
+ const desc = String(e.description || '').replace(/\s+/g, ' ').trim();
112
+ const cut = desc.search(/\bTriggers?:/i);
113
+ let does = (cut >= 0 ? desc.slice(0, cut) : desc).trim();
114
+ const triggers = cut >= 0 ? desc.slice(cut).replace(/^Triggers?:\s*/i, '') : '';
115
+
116
+ const rankMatch = /\b(Metic\+|Archon)[- ]only\b\.?/i.exec(does);
117
+ const rank = rankMatch ? (/archon/i.test(rankMatch[1]) ? 'archon' : 'metic') : null;
118
+ if (rankMatch) does = does.replace(rankMatch[0], '').replace(/\s{2,}/g, ' ').trim();
119
+
120
+ const quoted = [...triggers.matchAll(/"([^"]+)"/g)].map((m) => m[1].trim());
121
+ const commands = quoted.filter((q) => q.startsWith('/'));
122
+ const phrases = quoted.filter((q) => !q.startsWith('/'));
123
+ const otherwise = /\bor\s+(?!")([^"]+?)\.?$/i.exec(triggers);
124
+
125
+ let reachFor = e.reachFor || '';
126
+ if (!reachFor && (phrases.length || otherwise)) {
127
+ const said = phrases.slice(0, 3).map((p) => `“${p}”`).join(', ');
128
+ reachFor = [said && `When you would say something like ${said}`, otherwise && otherwise[1].trim()]
129
+ .filter(Boolean).join(' — or ');
130
+ reachFor = reachFor.charAt(0).toUpperCase() + reachFor.slice(1);
131
+ if (!/[.!?]$/.test(reachFor)) reachFor += '.';
132
+ }
133
+
134
+ let cost = e.cost || '';
135
+ if (!cost) {
136
+ const readOnly = /\bread-only\b|\bchanges nothing\b/i.test(desc);
137
+ const paid = /\bpaid\b/i.test(desc);
138
+ if (readOnly && paid) cost = 'Read-only as it normally runs, but its author flags a paid option — check before you use that one. Otherwise it uses your session like any other request.';
139
+ else if (readOnly) cost = 'Read-only: it looks things up and changes nothing. It uses your session like any other request.';
140
+ else if (paid) cost = 'Has a paid step — its author flags one in the description. Otherwise it uses your session like any other request.';
141
+ else cost = 'Its author has not written down what it costs. Running it uses your session like any other request.';
142
+ }
143
+
144
+ return {
145
+ name: e.name,
146
+ source: e.source || null,
147
+ does: e.plain || does,
148
+ reachFor,
149
+ cost,
150
+ commands,
151
+ needs: Array.isArray(e.requires) ? e.requires : [],
152
+ rank,
153
+ derived: { does: !e.plain, reachFor: !e.reachFor, cost: !e.cost },
154
+ };
155
+ }
156
+
61
157
  /**
62
158
  * The skills this instance has: Map name -> { name, description, source }.
63
159
  * First root wins on a duplicate name (core, then instance, then modules), the
@@ -91,4 +187,4 @@ function installedSkillsCached(opts, now = Date.now()) {
91
187
  }
92
188
  function _resetCache() { cached = null; }
93
189
 
94
- module.exports = { SKILL_NAME_RE, installedSkills, installedSkillsCached, readDescription, _resetCache };
190
+ module.exports = { SKILL_NAME_RE, installedSkills, installedSkillsCached, readDescription, parseFrontmatter, explainSkill, _resetCache };
@@ -18,8 +18,10 @@
18
18
  // OFFERS skills (`skills`), each adopter's ENABLED set
19
19
  // starts empty (`enabled_skills`), and only a skill this
20
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
21
+ // that fills the enabled set was built in task 1004073
22
+ // (GET /specialities/:id/skills + the hall's settings
23
+ // panel); the session line naming it is task 1004074.
24
+ // Naming a skill grants
23
25
  // nothing.
24
26
  // The genuinely new part is the knowledge bundle, which is why the migration adds
25
27
  // tables for it and this file carries the rules over them.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.2",
3
+ "version": "1.20.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.2",
9
+ "version": "1.20.3",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.2",
3
+ "version": "1.20.3",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -8052,5 +8052,11 @@
8052
8052
  "id": "1003631",
8053
8053
  "text": "Closed a small consistency gap in the hall's needs-attention inbox: one link is now escaped like all the others. No visible change."
8054
8054
  }
8055
+ ],
8056
+ "1.20.3": [
8057
+ {
8058
+ "id": "1004073",
8059
+ "text": "Adopting a speciality now walks you through its skills one at a time: what each does, when you'd use it, and what it costs, and you choose on or off for each before seeing the next. Nothing is switched on unless you choose it."
8060
+ }
8055
8061
  ]
8056
8062
  }
@@ -160,6 +160,10 @@ const CANNED_WRITES = [
160
160
  // sent-back lines the artboard draws render too.
161
161
  ['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/approve$/, 'copy-desk__pages__approve.post'],
162
162
  ['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/send-back$/, 'copy-desk__pages__send-back.post'],
163
+ // a speciality's adopt and the walkthrough's save (task 1004073), so adopting
164
+ // opens the skills walkthrough and its recap can save in the harness.
165
+ ['POST', /^specialities\/\d+\/adopt$/, 'specialities__9__adopt.post'],
166
+ ['PUT', /^specialities\/\d+\/skills$/, 'specialities__9__skills.put'],
163
167
  ];
164
168
 
165
169
  // A DATE THAT ROTS CANNOT DEMO AN AGE SIGNAL (task 1004041). The help-request
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.2'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.3'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -58,6 +58,46 @@ await test('the real tree resolves: the core ships skills a speciality can offer
58
58
  assert.ok(got.has('recall') && got.has('status'), 'core skills are found in this checkout');
59
59
  });
60
60
 
61
+ await test('a FOLDED description (`>-`) is read as its text, not as the fold marker (task 1004073)', () => {
62
+ const fm = skills.parseFrontmatter('---\nname: x\ndescription: >-\n One line\n and the next.\nrequires: [gh, droplet-ssh]\ncost: "Free."\n---\nbody');
63
+ assert.equal(fm.description, 'One line and the next.');
64
+ assert.deepEqual(fm.requires, ['gh', 'droplet-ssh']);
65
+ assert.equal(fm.cost, 'Free.');
66
+ assert.equal(skills.parseFrontmatter('---\ndescription: |\n a\n b\n---\n').description, 'a\nb', 'a literal block keeps its lines');
67
+ assert.deepEqual(skills.parseFrontmatter('no frontmatter'), {});
68
+ });
69
+
70
+ await test('every skill in the real tree reads a real description — none is a bare fold marker', () => {
71
+ const got = skills.installedSkills({ coreRoot: ROOT, isModuleEnabled: () => false });
72
+ const bad = [...got.values()].filter((s) => !s.description || /^[>|][+-]?$/.test(s.description)).map((s) => s.name);
73
+ assert.deepEqual(bad, [], `these would teach nothing in the walkthrough: ${bad.join(', ')}`);
74
+ });
75
+
76
+ await test('explainSkill splits a model-facing description into what it does, when, and what it costs', () => {
77
+ const e = skills.explainSkill({
78
+ name: 'goal-review', source: 'core',
79
+ description: 'Walk the review queue. Read-only. Metic+ only. Triggers: "/goal-review", "review goals", "confirm criteria", or a scheduled run.',
80
+ requires: ['gh'],
81
+ });
82
+ assert.equal(e.does, 'Walk the review queue. Read-only.', 'the trigger list and the rank note are not part of what it does');
83
+ assert.equal(e.rank, 'metic', 'the rank is kept apart: enabling a skill grants nothing');
84
+ assert.deepEqual(e.commands, ['/goal-review']);
85
+ assert.match(e.reachFor, /“review goals”, “confirm criteria”.*a scheduled run/);
86
+ assert.match(e.cost, /^Read-only/);
87
+ assert.deepEqual(e.needs, ['gh']);
88
+ assert.deepEqual(e.derived, { does: true, reachFor: true, cost: true });
89
+ });
90
+
91
+ await test('explainSkill prefers the author\'s people-facing words and says when it had to guess', () => {
92
+ const e = skills.explainSkill({ name: 'x', description: 'Model text. Triggers: "/x".', plain: 'Plain words.', cost: 'About a dollar.' });
93
+ assert.equal(e.does, 'Plain words.');
94
+ assert.equal(e.cost, 'About a dollar.');
95
+ assert.deepEqual(e.derived, { does: false, reachFor: true, cost: false });
96
+ const unknown = skills.explainSkill({ name: 'y', description: 'Does a thing.' });
97
+ assert.match(unknown.cost, /has not written down what it costs/, 'an unknown cost is said plainly, never invented');
98
+ assert.match(skills.explainSkill({ name: 'z', description: 'Read-only check. --probe runs a paid calibration.' }).cost, /paid option/);
99
+ });
100
+
61
101
  await test('an unreadable root contributes nothing rather than throwing', () => {
62
102
  const got = skills.installedSkills({ coreRoot: path.join(os.tmpdir(), 'no-such-root-' + Date.now()), isModuleEnabled: () => { throw new Error('boom'); } });
63
103
  assert.equal(got.size, 0);
@@ -146,7 +186,12 @@ db.setEnabledSkills = async (b, s, list) => (store.adoption ? { ...store.adoptio
146
186
  db.createSpeciality = async (row) => { store.created = row; return { id: 10, ...row }; };
147
187
  db.updateSpeciality = async (id, patch) => { store.updated = patch; return { ...store.speciality, ...patch }; };
148
188
  db.countTraining = async () => ({ documentCount: 0, learningCount: 0 });
149
- skills.installedSkillsCached = () => new Map([['recall', {}], ['status', {}], ['costing', {}]]);
189
+ skills.installedSkillsCached = () => new Map([
190
+ ['recall', { name: 'recall', description: 'Search what the project knows. Read-only. Triggers: "/recall", "what do we know about X".', source: 'core' }],
191
+ ['status', { name: 'status', description: 'Roll up a version.', source: 'core' }],
192
+ ['costing', { name: 'costing', description: 'Price a thing.', source: 'module:econ' }],
193
+ ]);
194
+ db.listAdoptions = async () => (store.adoption ? [{ id: 9, active: store.adoption.active, enabled_skills: store.adoption.enabled_skills }] : []);
150
195
  api.requireBuilder = (req, res, next) => { req.builder = { id: 1, rank: 'metic' }; next(); };
151
196
  api.enabledDisciplines = () => ['ideator', 'engineer', 'artist'];
152
197
 
@@ -187,6 +232,36 @@ try {
187
232
  store.speciality.skills = ['recall', 'status'];
188
233
  });
189
234
 
235
+ await test('GET /specialities/:id/skills explains each OFFERED skill for the walkthrough, and the caller\'s own set', async () => {
236
+ store.adoption = null;
237
+ store.speciality.skills = ['recall', 'gone-now'];
238
+ const r = await call('GET', '/specialities/9/skills');
239
+ assert.equal(r.status, 200, JSON.stringify(r.body));
240
+ assert.equal(r.body.adopted, false);
241
+ assert.deepEqual(r.body.enabled, []);
242
+ assert.deepEqual(r.body.skills.map((s) => s.name), ['recall', 'gone-now'], 'the offered set, in its order — not every installed skill');
243
+ const recall = r.body.skills[0];
244
+ assert.equal(recall.installed, true);
245
+ assert.equal(recall.does, 'Search what the project knows. Read-only.');
246
+ assert.match(recall.reachFor, /what do we know about X/);
247
+ assert.match(recall.cost, /^Read-only/);
248
+ assert.deepEqual(r.body.skills[1], { name: 'gone-now', installed: false }, 'a skill removed since is named, not explained or offered');
249
+
250
+ store.adoption = { builder_id: 1, speciality_id: 9, active: true, enabled_skills: ['recall'] };
251
+ const held = await call('GET', '/specialities/9/skills');
252
+ assert.equal(held.body.adopted, true);
253
+ assert.deepEqual(held.body.enabled, ['recall']);
254
+ store.speciality.skills = ['recall', 'status'];
255
+ });
256
+
257
+ await test('GET /specialities/:id/skills is 404 for a speciality the caller cannot see', async () => {
258
+ const saved = store.speciality;
259
+ store.speciality = { ...saved, owner_builder_id: 2, visibility: 'private' };
260
+ const r = await call('GET', '/specialities/9/skills');
261
+ assert.equal(r.status, 404);
262
+ store.speciality = saved;
263
+ });
264
+
190
265
  await test('PUT /specialities/:id/skills needs an active adoption', async () => {
191
266
  store.adoption = null;
192
267
  const r = await call('PUT', '/specialities/9/skills', { enabled_skills: ['recall'] });