@bongos/core 1.20.1 → 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.
- package/.bongos-core.json +40 -35
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
- package/docs/api/openapi.json +70 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +32 -18
- package/docs/copy-registry.json +153 -27
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +131 -91
- package/modules/hall-ui/public/goal-inbox.js +1 -1
- package/modules/hall-ui/public/settings-specialities.js +169 -5
- package/modules/hall-ui/public/settings.css +52 -1
- package/modules/hall-ui/public/settings.states.json +2 -1
- package/modules/hall-ui/records/panel-family.md +2 -0
- package/modules/specialities/routes/specialities.js +27 -0
- package/modules/specialities/skills.js +109 -13
- package/modules/specialities/specialities.js +4 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/hall-preview/server.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/rot_inbox.mjs +8 -0
- package/tests/speciality_skills.mjs +76 -1
- package/tests/speciality_walkthrough_ui.mjs +218 -0
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
const { escapeHtml, $ } = window.OTB;
|
|
25
25
|
const toast = (msg) => window.OTB.toast(msg);
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
// `walk` is the skills walkthrough in progress, or null (task 1004073):
|
|
28
|
+
// { id, data, step, decisions, saving }. See walkHtml below.
|
|
29
|
+
const state = { rows: [], suggestions: [], openId: null, busy: false, walk: null };
|
|
28
30
|
|
|
29
31
|
const el = (id) => document.getElementById(id);
|
|
30
32
|
const statusLine = (msg) => { const s = el('specialities-status'); if (s) s.textContent = msg || ''; };
|
|
@@ -73,10 +75,148 @@
|
|
|
73
75
|
</details>`;
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
// ── THE SKILLS WALKTHROUGH (task 1004073; ADR 0310 §3) ────────────────────
|
|
79
|
+
//
|
|
80
|
+
// The owner's ruling: an adopted speciality's skills start OFF, "and you
|
|
81
|
+
// should walk through what comes with each, people need to learn the system".
|
|
82
|
+
// So this is deliberately NOT a list of checkboxes. It shows ONE skill at a
|
|
83
|
+
// time — what it does, when to reach for it, what it costs — and moves on only
|
|
84
|
+
// once the person has decided about that one. There is no "turn them all on"
|
|
85
|
+
// and no skip: the bar is that someone who finishes can say what each skill
|
|
86
|
+
// does, and a control that decides without reading defeats it.
|
|
87
|
+
//
|
|
88
|
+
// Nothing is written until the recap's "Save my choices", and that sends the
|
|
89
|
+
// WHOLE set (PUT replaces it), so walking away half-way leaves every skill off —
|
|
90
|
+
// the default the ruling asked for. Turning a skill on grants nothing: a
|
|
91
|
+
// rank-gated skill stays rank-gated, and the step says so before you choose.
|
|
92
|
+
|
|
93
|
+
const walkSteps = (w) => (w.data.skills || []).filter((k) => k.installed);
|
|
94
|
+
|
|
95
|
+
const RANK_COPY = {
|
|
96
|
+
metic: 'Only builders of Metic rank or above can run it. Turning it on here does not change that.',
|
|
97
|
+
archon: 'Only the project owner (Archon) can run it. Turning it on here does not change that.',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
async function openWalk(id) {
|
|
101
|
+
try {
|
|
102
|
+
const data = await call('GET', `/specialities/${id}/skills`);
|
|
103
|
+
if (!data) return;
|
|
104
|
+
if (!walkSteps({ data }).length) { toast('This speciality offers no skills to choose.'); return; }
|
|
105
|
+
state.walk = { id: String(id), data, step: 0, decisions: {}, saving: false };
|
|
106
|
+
state.openId = null;
|
|
107
|
+
render();
|
|
108
|
+
focusWalk();
|
|
109
|
+
} catch (err) { statusLine(`Could not load its skills — ${err.message}`); }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Each step replaces the panel's contents, so focus goes to the new heading —
|
|
113
|
+
// otherwise a keyboard or screen-reader user is left on a button that no
|
|
114
|
+
// longer exists. Focus only; nothing animates.
|
|
115
|
+
function focusWalk() {
|
|
116
|
+
const h = document.querySelector('.skill-walk [data-walk-focus]');
|
|
117
|
+
if (h) h.focus();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function stepHtml(w, k, i, total) {
|
|
121
|
+
const was = w.data.enabled.includes(k.name);
|
|
122
|
+
const decided = w.decisions[k.name];
|
|
123
|
+
const commands = (k.commands || []).length
|
|
124
|
+
? ` You can also start it yourself by typing <code>${escapeHtml(k.commands[0])}</code>.` : '';
|
|
125
|
+
const guessed = k.derived && (k.derived.does || k.derived.cost)
|
|
126
|
+
? '<p class="prow__hint skill-walk__note">These words come from the skill’s own description, which was written for Claude to recognise rather than for you to read — so a term may go unexplained.</p>'
|
|
127
|
+
: '';
|
|
128
|
+
return `
|
|
129
|
+
<p class="skill-walk__progress">Skill ${i + 1} of ${total} in ${escapeHtml(w.data.speciality.name)}</p>
|
|
130
|
+
<h4 class="skill-walk__name" tabindex="-1" data-walk-focus>${escapeHtml(k.name)}</h4>
|
|
131
|
+
<dl class="skill-walk__facts">
|
|
132
|
+
<dt>What it does</dt><dd>${escapeHtml(k.does || 'Its author gave no description.')}</dd>
|
|
133
|
+
<dt>When to reach for it</dt><dd>${escapeHtml(k.reachFor || 'Its author did not say.')}${commands}</dd>
|
|
134
|
+
<dt>What it costs</dt><dd>${escapeHtml(k.cost)}</dd>
|
|
135
|
+
${(k.needs || []).length ? `<dt>What it needs on your machine</dt><dd>${k.needs.map((n) => `<code>${escapeHtml(n)}</code>`).join(', ')}</dd>` : ''}
|
|
136
|
+
${k.rank && RANK_COPY[k.rank] ? `<dt>Who can run it</dt><dd>${RANK_COPY[k.rank]}</dd>` : ''}
|
|
137
|
+
</dl>
|
|
138
|
+
${guessed}
|
|
139
|
+
${was ? '<p class="prow__hint">You have this one on at the moment.</p>' : ''}
|
|
140
|
+
<div class="speciality-actions skill-walk__actions">
|
|
141
|
+
${i > 0 ? '<button type="button" class="pbtn pbtn--quiet" data-walk="back">Back</button>' : ''}
|
|
142
|
+
<button type="button" class="pbtn" data-walk="off" aria-pressed="${decided === false}">Leave it off</button>
|
|
143
|
+
<button type="button" class="pbtn" data-walk="on" aria-pressed="${decided === true}">Turn it on</button>
|
|
144
|
+
</div>`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function recapHtml(w, steps) {
|
|
148
|
+
const on = steps.filter((k) => w.decisions[k.name]).length;
|
|
149
|
+
const gone = (w.data.skills || []).filter((k) => !k.installed);
|
|
150
|
+
return `
|
|
151
|
+
<h4 class="skill-walk__name" tabindex="-1" data-walk-focus>Your choices for ${escapeHtml(w.data.speciality.name)}</h4>
|
|
152
|
+
<p class="prow__hint">${on} of ${steps.length} turned on. Each one, in a line:</p>
|
|
153
|
+
<ul class="skill-walk__recap">
|
|
154
|
+
${steps.map((k) => `
|
|
155
|
+
<li>
|
|
156
|
+
<strong>${escapeHtml(k.name)}</strong>
|
|
157
|
+
<span class="speciality-badge${w.decisions[k.name] ? ' speciality-badge--on' : ''}">${w.decisions[k.name] ? 'on' : 'off'}</span>
|
|
158
|
+
<span class="prow__hint">${escapeHtml(firstSentence(k.does))}</span>
|
|
159
|
+
</li>`).join('')}
|
|
160
|
+
</ul>
|
|
161
|
+
${gone.length ? `<p class="prow__hint">Also offered, but no longer on this project, so it cannot be turned on: ${gone.map((k) => `<code>${escapeHtml(k.name)}</code>`).join(', ')}.</p>` : ''}
|
|
162
|
+
<div class="speciality-actions skill-walk__actions">
|
|
163
|
+
<button type="button" class="pbtn pbtn--quiet" data-walk="restart">Go through them again</button>
|
|
164
|
+
<button type="button" class="pbtn pbtn--save" data-walk="save" ${w.saving ? 'disabled' : ''}>Save my choices</button>
|
|
165
|
+
</div>`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function firstSentence(text) {
|
|
169
|
+
const t = String(text || '');
|
|
170
|
+
const m = /^(.+?[.!?])(\s|$)/.exec(t);
|
|
171
|
+
return m ? m[1] : t;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function walkHtml() {
|
|
175
|
+
const w = state.walk;
|
|
176
|
+
const steps = walkSteps(w);
|
|
177
|
+
const body = w.step < steps.length ? stepHtml(w, steps[w.step], w.step, steps.length) : recapHtml(w, steps);
|
|
178
|
+
return `<section class="skill-walk" aria-label="Choose the skills for ${escapeHtml(w.data.speciality.name)}">
|
|
179
|
+
${body}
|
|
180
|
+
<button type="button" class="skill-walk__later" data-walk="close">Finish later — everything stays off until you save</button>
|
|
181
|
+
</section>`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function walkAct(action) {
|
|
185
|
+
const w = state.walk;
|
|
186
|
+
if (!w) return;
|
|
187
|
+
const steps = walkSteps(w);
|
|
188
|
+
if (action === 'close') { state.walk = null; render(); return; }
|
|
189
|
+
if (action === 'back') w.step = Math.max(0, w.step - 1);
|
|
190
|
+
if (action === 'restart') w.step = 0;
|
|
191
|
+
if ((action === 'on' || action === 'off') && w.step < steps.length) {
|
|
192
|
+
w.decisions[steps[w.step].name] = action === 'on';
|
|
193
|
+
w.step += 1;
|
|
194
|
+
}
|
|
195
|
+
if (action === 'save') {
|
|
196
|
+
w.saving = true;
|
|
197
|
+
render();
|
|
198
|
+
try {
|
|
199
|
+
const enabled = steps.filter((k) => w.decisions[k.name]).map((k) => k.name);
|
|
200
|
+
await call('PUT', `/specialities/${w.id}/skills`, { enabled_skills: enabled });
|
|
201
|
+
state.walk = null;
|
|
202
|
+
toast(`Saved — ${enabled.length} of ${steps.length} skill${steps.length === 1 ? '' : 's'} on.`);
|
|
203
|
+
await load();
|
|
204
|
+
return;
|
|
205
|
+
} catch (err) {
|
|
206
|
+
w.saving = false;
|
|
207
|
+
statusLine(`Could not save your choices — ${err.message}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
render();
|
|
211
|
+
focusWalk();
|
|
212
|
+
}
|
|
213
|
+
|
|
76
214
|
function cardHtml(s) {
|
|
77
215
|
const trained = Number(s.documentCount || 0);
|
|
78
216
|
const learned = Number(s.learningCount || 0);
|
|
79
217
|
const open = String(state.openId) === String(s.id);
|
|
218
|
+
const offered = Array.isArray(s.skills) ? s.skills.length : 0;
|
|
219
|
+
const walking = state.walk && state.walk.id === String(s.id);
|
|
80
220
|
return `
|
|
81
221
|
<li class="prow speciality-card" data-id="${escapeHtml(String(s.id))}">
|
|
82
222
|
<div class="prow__main">
|
|
@@ -91,15 +231,18 @@
|
|
|
91
231
|
Trained on <strong>${trained}</strong> document${trained === 1 ? '' : 's'} you added
|
|
92
232
|
· learned <strong>${learned}</strong> thing${learned === 1 ? '' : 's'} from your sessions
|
|
93
233
|
· shared with: ${escapeHtml(VISIBILITY_COPY[s.visibility] || s.visibility || 'only you')}
|
|
234
|
+
${offered ? `· offers <strong>${offered}</strong> skill${offered === 1 ? '' : 's'}${s.active ? `, <strong>${(s.enabled_skills || []).length}</strong> on for you` : ''}` : ''}
|
|
94
235
|
</p>
|
|
95
236
|
</div>
|
|
96
237
|
<div class="prow__actions">
|
|
238
|
+
${s.active && offered && !walking ? `<button type="button" class="btn btn--quiet" data-act="walk" data-id="${escapeHtml(String(s.id))}">Choose its skills</button>` : ''}
|
|
97
239
|
${s.mine ? `<button type="button" class="btn btn--quiet" data-act="train" data-id="${escapeHtml(String(s.id))}">${open ? 'Close' : 'Train'}</button>` : ''}
|
|
98
240
|
${s.active
|
|
99
241
|
? `<button type="button" class="btn btn--quiet" data-act="abandon" data-id="${escapeHtml(String(s.id))}">Stop using</button>`
|
|
100
242
|
: `<button type="button" class="btn" data-act="adopt" data-id="${escapeHtml(String(s.id))}" ${s.adoptable ? '' : 'disabled'}>Use this</button>`}
|
|
101
243
|
</div>
|
|
102
244
|
${open ? trainHtml(s) : ''}
|
|
245
|
+
${walking ? walkHtml() : ''}
|
|
103
246
|
</li>`;
|
|
104
247
|
}
|
|
105
248
|
|
|
@@ -183,10 +326,18 @@
|
|
|
183
326
|
]);
|
|
184
327
|
if (!cat) return;
|
|
185
328
|
state.suggestions = (offered && offered.suggestions) || [];
|
|
186
|
-
const
|
|
187
|
-
state.rows = ((cat && cat.specialities) || []).map((s) =>
|
|
329
|
+
const active = new Map(((mine && mine.adoptions) || []).filter((a) => a.active).map((a) => [String(a.id), a]));
|
|
330
|
+
state.rows = ((cat && cat.specialities) || []).map((s) => {
|
|
331
|
+
const a = active.get(String(s.id));
|
|
332
|
+
return { ...s, active: !!a, enabled_skills: (a && a.enabled_skills) || [] };
|
|
333
|
+
});
|
|
188
334
|
render();
|
|
189
335
|
statusLine('');
|
|
336
|
+
// The section ships `hidden`, like every settings panel, and is revealed
|
|
337
|
+
// once its reads answer. Nothing revealed this one until task 1004073, so
|
|
338
|
+
// the panel — and adoption with it — was unreachable from the hall.
|
|
339
|
+
const section = el('specialities-scroll');
|
|
340
|
+
if (section) section.hidden = false;
|
|
190
341
|
} catch (err) {
|
|
191
342
|
statusLine(`Could not load specialities — ${err.message}`);
|
|
192
343
|
}
|
|
@@ -197,8 +348,18 @@
|
|
|
197
348
|
state.busy = true;
|
|
198
349
|
try {
|
|
199
350
|
if (action === 'adopt') { await call('POST', `/specialities/${id}/adopt`); toast('Using this speciality.'); }
|
|
200
|
-
if (action === 'abandon') {
|
|
351
|
+
if (action === 'abandon') {
|
|
352
|
+
await call('POST', `/specialities/${id}/abandon`);
|
|
353
|
+
toast('Stopped using it.');
|
|
354
|
+
if (state.walk && state.walk.id === String(id)) state.walk = null;
|
|
355
|
+
}
|
|
201
356
|
await load();
|
|
357
|
+
// Adopting is the moment the ruling is about: a speciality that offers
|
|
358
|
+
// skills opens straight into the walkthrough, with every one still off.
|
|
359
|
+
if (action === 'adopt') {
|
|
360
|
+
const row = state.rows.find((s) => String(s.id) === String(id));
|
|
361
|
+
if (row && Array.isArray(row.skills) && row.skills.length) await openWalk(id);
|
|
362
|
+
}
|
|
202
363
|
} catch (err) {
|
|
203
364
|
statusLine(err.message);
|
|
204
365
|
} finally {
|
|
@@ -221,10 +382,13 @@
|
|
|
221
382
|
const host = el('specialities-rows');
|
|
222
383
|
if (host) {
|
|
223
384
|
host.addEventListener('click', (e) => {
|
|
385
|
+
const walkBtn = e.target.closest('button[data-walk]');
|
|
386
|
+
if (walkBtn) { walkAct(walkBtn.dataset.walk); return; }
|
|
224
387
|
const btn = e.target.closest('button[data-act]');
|
|
225
388
|
if (!btn) return;
|
|
226
389
|
const { act: action, id } = btn.dataset;
|
|
227
|
-
if (action === '
|
|
390
|
+
if (action === 'walk') { openWalk(id); return; }
|
|
391
|
+
if (action === 'train') { state.openId = String(state.openId) === String(id) ? null : id; state.walk = null; render(); return; }
|
|
228
392
|
act(action, id);
|
|
229
393
|
});
|
|
230
394
|
|
|
@@ -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
|
-
|
|
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
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
22
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -8046,5 +8046,17 @@
|
|
|
8046
8046
|
"id": "1004368",
|
|
8047
8047
|
"text": "CI checks were failing on every change because of a newly published security flaw in a networking library; updating it made the checks pass again so approved work can land."
|
|
8048
8048
|
}
|
|
8049
|
+
],
|
|
8050
|
+
"1.20.2": [
|
|
8051
|
+
{
|
|
8052
|
+
"id": "1003631",
|
|
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
|
+
}
|
|
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
|
+
}
|
|
8049
8061
|
]
|
|
8050
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.
|
|
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');
|
package/tests/rot_inbox.mjs
CHANGED
|
@@ -630,3 +630,11 @@ test('acting on a rot line refreshes the ROT slice, not the goals list', () => {
|
|
|
630
630
|
assert.match(GOAL_INBOX_SRC, /ROT_TOASTS\[verb\] \|\| 'Done\.', loadRot\)/,
|
|
631
631
|
'refreshing the wrong slice leaves the acted-on row sitting there — the task-1003066 lesson');
|
|
632
632
|
});
|
|
633
|
+
|
|
634
|
+
test('rotRowHtml escapes the row href like every sibling field (task 1003631)', () => {
|
|
635
|
+
const start = GOAL_INBOX_SRC.indexOf('function rotRowHtml');
|
|
636
|
+
const body = GOAL_INBOX_SRC.slice(start, GOAL_INBOX_SRC.indexOf('\n }\n', start));
|
|
637
|
+
assert.ok(start > 0, 'rotRowHtml exists');
|
|
638
|
+
assert.match(body, /href="\$\{escapeHtml\(href\)\}"/, 'href goes through escapeHtml');
|
|
639
|
+
assert.doesNotMatch(body, /href="\$\{href\}"/, 'no raw href interpolation');
|
|
640
|
+
});
|