@bongos/core 1.21.87 → 1.21.89
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 +31 -21
- package/docs/copy-inventory.md +17 -17
- package/docs/copy-registry.json +19 -19
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +415 -416
- package/modules/hall-ui/public/genesis-home.js +26 -1
- package/modules/hall-ui/public/street-amend.js +57 -4
- package/modules/hall-ui/public/street.js +13 -0
- package/modules/onboarding/genesis-home.js +43 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/claude-materialize.js +86 -4
- package/scripts/gds/retired-core-skills.json +101 -0
- package/src/module-api.js +1 -1
- package/tests/claude_materialize.mjs +82 -0
- package/tests/genesis_government.mjs +220 -0
|
@@ -33,6 +33,17 @@
|
|
|
33
33
|
not_deciding: 'The plan is already settled.',
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
+
// What saving the plan did to the government, when it was not settled in the same
|
|
37
|
+
// click. The server's charter outcome (modules/onboarding/genesis-home.js
|
|
38
|
+
// applyGovernmentChoice); in_force and none need no sentence.
|
|
39
|
+
function charterNote(c) {
|
|
40
|
+
if (!c) return '';
|
|
41
|
+
if (c.state === 'sitting') return 'Your starting government is in the Board Room for a vote.';
|
|
42
|
+
if (c.state === 'needs_council') return 'A Foundation needs a council first. Add a council rank, then choose Foundation on the Charter.';
|
|
43
|
+
if (c.state === 'not_filed') return 'The plan is saved, but the government could not be changed just now. You can change it on the Charter.';
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
function projectName() {
|
|
37
48
|
var b = window.__BRANDING__ || {};
|
|
38
49
|
return (b.identity && b.identity.productName) || 'the project';
|
|
@@ -307,7 +318,21 @@
|
|
|
307
318
|
var t = e.target.closest('button');
|
|
308
319
|
if (!t || !host.contains(t)) return;
|
|
309
320
|
if (t.hasAttribute('data-genesis-plan')) {
|
|
310
|
-
|
|
321
|
+
// The government picked here is filed as a charter amendment with the plan
|
|
322
|
+
// (task 1004656). When the founder's one yes decides it, saving the plan is
|
|
323
|
+
// that yes too, the same one-click fast-track the stage closes take.
|
|
324
|
+
act(send('POST', '/founding/plan', planBody(host)).then(function (out) {
|
|
325
|
+
var c = out && out.charter;
|
|
326
|
+
if (c && c.state === 'sitting' && c.one_vote_decides && c.item_id) {
|
|
327
|
+
return send('POST', '/government/board/items/' + encodeURIComponent(c.item_id) + '/votes', { direction: 'yes' })
|
|
328
|
+
.catch(function () { return null; });
|
|
329
|
+
}
|
|
330
|
+
var said = charterNote(c);
|
|
331
|
+
if (!said) return null;
|
|
332
|
+
toast(said);
|
|
333
|
+
// Give the sentence time to be read before the reload takes it away.
|
|
334
|
+
return new Promise(function (done) { setTimeout(done, 3500); });
|
|
335
|
+
}).then(function () { window.location.reload(); }));
|
|
311
336
|
} else if (t.hasAttribute('data-genesis-close')) {
|
|
312
337
|
// The solo fast-track (spec D4): when the founder's one yes decides the sitting
|
|
313
338
|
// and nobody else is asked, the close and the confirm are one click.
|
|
@@ -50,6 +50,38 @@
|
|
|
50
50
|
var d = constitution && constitution.decision_rules;
|
|
51
51
|
return d && Array.isArray(d.rules) ? d.rules.filter(function (r) { return r && r.key; }) : [];
|
|
52
52
|
}
|
|
53
|
+
// The forms of government a charter draft can pick in one tap (task 1004656), from
|
|
54
|
+
// the constitution's own charter block (modules/government/charter.js), so a name
|
|
55
|
+
// is never typed twice. A form is a franchise plus a pass rule; its seat is the
|
|
56
|
+
// franchise option's predicate. Foundation seats a council the owner names (a
|
|
57
|
+
// custom rank this page cannot know), so it is listed as needing one rather than
|
|
58
|
+
// offered; the forms that cannot be adopted yet are listed as later.
|
|
59
|
+
function formsOf(constitution) {
|
|
60
|
+
var ch = constitution && constitution.charter;
|
|
61
|
+
var forms = ch && Array.isArray(ch.forms) ? ch.forms : [];
|
|
62
|
+
var seats = {};
|
|
63
|
+
(ch && Array.isArray(ch.variables) ? ch.variables : []).forEach(function (v) {
|
|
64
|
+
if (v && v.key === 'franchise' && Array.isArray(v.options)) {
|
|
65
|
+
v.options.forEach(function (o) { if (o && o.key && o.predicate) seats[o.key] = o.predicate; });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
var out = { pick: [], council: [], later: [] };
|
|
69
|
+
forms.forEach(function (f) {
|
|
70
|
+
if (!f || !f.key) return;
|
|
71
|
+
if (!f.available) { out.later.push(str(f.name)); return; }
|
|
72
|
+
var seat = seats[f.franchise];
|
|
73
|
+
if (!seat || !FROM_PASS[f.pass_rule]) { out.council.push(str(f.name)); return; }
|
|
74
|
+
out.pick.push({ value: f.key, words: str(f.name), decided_by: seat, rule: FROM_PASS[f.pass_rule] });
|
|
75
|
+
});
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
// Which form the draft's settings make, or null (the name follows the settings).
|
|
79
|
+
function formOf(d) {
|
|
80
|
+
var list = (d && d.forms && d.forms.pick) || [];
|
|
81
|
+
for (var i = 0; i < list.length; i++) if (list[i].decided_by === d.decided_by && list[i].rule === d.rule) return list[i];
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
53
85
|
function rowOf(constitution, area) {
|
|
54
86
|
var as = areasOf(constitution) || [];
|
|
55
87
|
for (var i = 0; i < as.length; i++) if (as[i] && as[i].area === area) return as[i];
|
|
@@ -103,6 +135,7 @@
|
|
|
103
135
|
? { decided_by: board.membership, rule: FROM_PASS[board.pass_rule] || null }
|
|
104
136
|
: { decided_by: row.decided_by, rule: row.decided_by === 'gate' ? null : row.rule },
|
|
105
137
|
decided_by: null, rule: null, reason: '',
|
|
138
|
+
forms: charter ? formsOf(constitution) : { pick: [], council: [], later: [] },
|
|
106
139
|
};
|
|
107
140
|
if (charter) {
|
|
108
141
|
d.decided_by = board.membership;
|
|
@@ -131,11 +164,21 @@
|
|
|
131
164
|
}
|
|
132
165
|
function copy(d) { var o = {}; for (var k in d) if (Object.prototype.hasOwnProperty.call(d, k)) o[k] = d[k]; return o; }
|
|
133
166
|
|
|
134
|
-
// One pick: field 'who' | 'rule' | 'reason'. A value
|
|
135
|
-
// is refused (the draft comes back unchanged).
|
|
167
|
+
// One pick: field 'form' (the charter only) | 'who' | 'rule' | 'reason'. A value
|
|
168
|
+
// outside the vocabulary is refused (the draft comes back unchanged). A form sets
|
|
169
|
+
// who and which rule together; picking either afterwards leaves the form to be
|
|
170
|
+
// read back from the settings.
|
|
136
171
|
function pick(d, field, value) {
|
|
137
172
|
if (!d || !d.ok) return d;
|
|
138
173
|
var n = copy(d);
|
|
174
|
+
if (field === 'form') {
|
|
175
|
+
var list = (d.forms && d.forms.pick) || [], f = null;
|
|
176
|
+
for (var i = 0; i < list.length; i++) if (list[i].value === value) f = list[i];
|
|
177
|
+
if (!f || !hasValue(d.whoOptions, f.decided_by)) return d;
|
|
178
|
+
n.decided_by = f.decided_by;
|
|
179
|
+
n.rule = f.rule;
|
|
180
|
+
return settle(n);
|
|
181
|
+
}
|
|
139
182
|
if (field === 'who') { if (!hasValue(d.whoOptions, value)) return d; n.decided_by = value; }
|
|
140
183
|
else if (field === 'rule') { if (!hasValue(d.ruleOptions, value)) return d; n.rule = value; }
|
|
141
184
|
else if (field === 'reason') { n.reason = str(value).slice(0, REASON_MAX); return n; }
|
|
@@ -190,6 +233,10 @@
|
|
|
190
233
|
if (check(d)) return null;
|
|
191
234
|
var api = (card() && card().API) || '/api/bongos';
|
|
192
235
|
var why = str(d.reason).trim();
|
|
236
|
+
// A named form goes through the charter route, so the record reads
|
|
237
|
+
// "Charter: Direct Democracy" and the server builds the same constitution.
|
|
238
|
+
var form = d.charter ? formOf(d) : null;
|
|
239
|
+
if (form) return { method: 'POST', url: api + '/government/board/charter', body: { form: form.value, rationale_md: why } };
|
|
193
240
|
if (d.charter) return { method: 'POST', url: api + '/government/board/items', body: { subject_type: 'constitutional_amendment', proposal: proposal(d), rationale_md: why } };
|
|
194
241
|
return { method: 'POST', url: api + '/government/board/decision-rules', body: { rules: [ruleRow(d)], rationale_md: why } };
|
|
195
242
|
}
|
|
@@ -205,6 +252,9 @@
|
|
|
205
252
|
nobody_is_vacant: 'An empty building decides nothing.',
|
|
206
253
|
bad_membership: 'That is not a seat this project knows.',
|
|
207
254
|
bad_pass_rule: 'That is not a way the board can decide.',
|
|
255
|
+
council_needs_a_rank: 'A Foundation needs a council rank first.',
|
|
256
|
+
unknown_council_rank: 'There is no such council rank yet, so that would seat no one.',
|
|
257
|
+
form_not_available: 'That form of government is not available yet.',
|
|
208
258
|
};
|
|
209
259
|
function refusal(status, body) {
|
|
210
260
|
var e = body && body.error ? body.error : {};
|
|
@@ -213,7 +263,7 @@
|
|
|
213
263
|
if (e.code === 'no_change') return 'That already is how it works. Nothing was filed.';
|
|
214
264
|
if (status === 401) return 'Sign in to file an amendment.';
|
|
215
265
|
if (status === 403) return 'Filing an amendment is not yours to do here. The server refused it.';
|
|
216
|
-
if ((e.code === 'invalid_decision_rules' || e.code === 'invalid_proposal') && det.reason) {
|
|
266
|
+
if ((e.code === 'invalid_decision_rules' || e.code === 'invalid_proposal' || e.code === 'invalid_charter') && det.reason) {
|
|
217
267
|
return REASONS[det.reason] || 'The server refused it: ' + str(det.reason).replace(/_/g, ' ') + '.';
|
|
218
268
|
}
|
|
219
269
|
var C = card();
|
|
@@ -224,7 +274,9 @@
|
|
|
224
274
|
function answer(d, body) {
|
|
225
275
|
var item = body && body.item;
|
|
226
276
|
return {
|
|
227
|
-
toast:
|
|
277
|
+
toast: d && d.charter && formOf(d)
|
|
278
|
+
? 'Filed. Changing the government to ' + formOf(d).words + ' is on the Charter for the council to vote on.'
|
|
279
|
+
: 'Filed. Your amendment to ' + (d && d.name ? d.name : 'this building') + ' is on the Charter for the council to vote on.',
|
|
228
280
|
building: 'charter',
|
|
229
281
|
matter: item && item.id != null ? 'board:' + item.id : null,
|
|
230
282
|
kind: 'light',
|
|
@@ -234,6 +286,7 @@
|
|
|
234
286
|
var api = {
|
|
235
287
|
RANKS: RANKS, draftFor: draftFor, pick: pick, changed: changed, change: change,
|
|
236
288
|
check: check, request: request, refusal: refusal, answer: answer, whoOptions: whoOptions, ruleOptions: ruleOptions,
|
|
289
|
+
formsOf: formsOf, formOf: formOf,
|
|
237
290
|
};
|
|
238
291
|
root.GovAmend = api;
|
|
239
292
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
@@ -1099,6 +1099,19 @@
|
|
|
1099
1099
|
} else ch.appendChild(node('p', 'inside-note', 'This is how it works today. Pick who decides, or how, to change it.'));
|
|
1100
1100
|
box.appendChild(ch);
|
|
1101
1101
|
var picks = node('div', 'inside-draft__picks');
|
|
1102
|
+
// The Charter's draft leads with the forms of government, one tap each (task
|
|
1103
|
+
// 1004656): a form sets who decides and how together, and the chip lit is the
|
|
1104
|
+
// form the settings make, so a hand-picked mix lights none.
|
|
1105
|
+
if (d.charter && d.forms && d.forms.pick.length) {
|
|
1106
|
+
var lit = A.formOf(d);
|
|
1107
|
+
picks.appendChild(draftField('government', 'insideDraftForm', draftChips('insideDraftForm', d.forms.pick, lit ? lit.value : null, function (v) {
|
|
1108
|
+
dr.d = A.pick(dr.d, 'form', v); dr.error = ''; bumpDraft('insideDraftForm');
|
|
1109
|
+
}), true));
|
|
1110
|
+
var aside = [];
|
|
1111
|
+
if (d.forms.council.length) aside.push(d.forms.council.join(', ') + ' needs a council rank first.');
|
|
1112
|
+
if (d.forms.later.length) aside.push('Coming later: ' + d.forms.later.join(', ') + '.');
|
|
1113
|
+
if (aside.length) picks.appendChild(node('p', 'inside-note inside-draft__later', aside.join(' ')));
|
|
1114
|
+
}
|
|
1102
1115
|
picks.appendChild(draftField('who decides', 'insideDraftWho', draftChips('insideDraftWho', d.whoOptions, d.decided_by, function (v) {
|
|
1103
1116
|
dr.d = A.pick(dr.d, 'who', v); dr.error = ''; bumpDraft('insideDraftWho');
|
|
1104
1117
|
}), true));
|
|
@@ -57,8 +57,8 @@ const MAX_DAYS = 90;
|
|
|
57
57
|
const intIn = (v, lo, hi) => Number.isInteger(v) && v >= lo && v <= hi;
|
|
58
58
|
|
|
59
59
|
// parsePlan(body) — PURE. The founder's plan, defaults filled in, or why it is refused.
|
|
60
|
-
//
|
|
61
|
-
//
|
|
60
|
+
// Here the government is only a well-formed form key; savePlan files it as a charter
|
|
61
|
+
// amendment once the plan is saved (applyGovernmentChoice, task 1004656).
|
|
62
62
|
function parsePlan(body = {}) {
|
|
63
63
|
const b = body && typeof body === 'object' ? body : {};
|
|
64
64
|
const days = {};
|
|
@@ -281,7 +281,46 @@ async function savePlan(body, builderId, deps = {}) {
|
|
|
281
281
|
await s.set(PLAN_KEY, JSON.stringify(parsed.plan), builderId);
|
|
282
282
|
const seeded = await s.lifecycle.seedGenesis({ builderId, existing: parsed.plan.existing });
|
|
283
283
|
await fm.setFoundingMode({ stage: 'scope' }, builderId, modeDeps(s));
|
|
284
|
-
|
|
284
|
+
const charter = await applyGovernmentChoice(parsed.plan.government, builderId, s);
|
|
285
|
+
return { ok: true, plan: parsed.plan, seeded: seeded && seeded.seeded, charter };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// applyGovernmentChoice — the government picked at decide becomes the one in force
|
|
289
|
+
// (task 1004656). It is filed as an ordinary charter amendment under the constitution
|
|
290
|
+
// in force (the government port's proposeCharter), so the board decides it like any
|
|
291
|
+
// change to the rules: under the monarchy default the founder's own yes applies it,
|
|
292
|
+
// and every later stage is then closed by the government they chose (spec D7).
|
|
293
|
+
// Never refuses the plan — the plan is saved first, and a choice that cannot be filed
|
|
294
|
+
// says why in `state`:
|
|
295
|
+
// none no government was picked
|
|
296
|
+
// in_force the constitution already reads as that form (Monarchy at birth)
|
|
297
|
+
// sitting filed; `item_id`, and `one_vote_decides` when one yes carries it
|
|
298
|
+
// needs_council Foundation, which needs a council rank before it can seat anyone
|
|
299
|
+
// not_filed anything else (`reason`), or no government module to file it with
|
|
300
|
+
async function applyGovernmentChoice(form, builderId, s) {
|
|
301
|
+
if (!form) return { state: 'none' };
|
|
302
|
+
const gov = s.government;
|
|
303
|
+
if (!gov || typeof gov.proposeCharter !== 'function') return { state: 'not_filed', form, reason: 'no_government' };
|
|
304
|
+
let out;
|
|
305
|
+
try {
|
|
306
|
+
out = await gov.proposeCharter({ form, proposedBy: builderId });
|
|
307
|
+
} catch (_) {
|
|
308
|
+
return { state: 'not_filed', form, reason: 'charter_failed' };
|
|
309
|
+
}
|
|
310
|
+
if (out && out.ok) {
|
|
311
|
+
const snap = (out.item && out.item.constitution) || {};
|
|
312
|
+
return {
|
|
313
|
+
state: 'sitting',
|
|
314
|
+
form,
|
|
315
|
+
item_id: out.item ? String(out.item.id) : null,
|
|
316
|
+
one_vote_decides: snap.pass_rule === 'first_ratifier',
|
|
317
|
+
reads_as: out.reads_as ? out.reads_as.name : null,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
const reason = (out && out.reason) || 'charter_failed';
|
|
321
|
+
if (reason === 'no_change') return { state: 'in_force', form };
|
|
322
|
+
if (reason === 'council_needs_a_rank') return { state: 'needs_council', form };
|
|
323
|
+
return { state: 'not_filed', form, reason };
|
|
285
324
|
}
|
|
286
325
|
|
|
287
326
|
// closeStage — put the current stage to the Board Room. The finished-tasks gate is the
|
|
@@ -335,7 +374,7 @@ async function onStagePassed(deps = {}) {
|
|
|
335
374
|
|
|
336
375
|
module.exports = {
|
|
337
376
|
PLAN_KEY, STAGE_INFO, PROPER, DEMO_PEOPLE, DEMO_HOURS, intIn, parsePlan, shapeGenesis, demoMinutes, demoPlan,
|
|
338
|
-
readGenesis, startFounding, savePlan, closeStage, comeAlive, onStagePassed,
|
|
377
|
+
readGenesis, startFounding, savePlan, applyGovernmentChoice, closeStage, comeAlive, onStagePassed,
|
|
339
378
|
// the founding-mode deps this file reads the mode with, for founding-birth.js (task 1004506)
|
|
340
379
|
modeDepsFor: (deps) => modeDeps(seams(deps)),
|
|
341
380
|
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.89",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.89",
|
|
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.21.
|
|
3
|
+
"version": "1.21.89",
|
|
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
|
@@ -9352,5 +9352,17 @@
|
|
|
9352
9352
|
"id": "1002852",
|
|
9353
9353
|
"text": "The task list a builder sees at the start of a session now opens with the goals they have joined and how much work in each is ready, and groups the tasks under their goals, with the builder's own goals first and anything witho"
|
|
9354
9354
|
}
|
|
9355
|
+
],
|
|
9356
|
+
"1.21.88": [
|
|
9357
|
+
{
|
|
9358
|
+
"id": "1004482",
|
|
9359
|
+
"text": "Upgrading a project now clears out commands Bongos used to ship but has since retired (the old dev-box commands, the old /dev /ideate /paint playbooks, the pixel-art commands), so the Commands page and Claude's menu only list"
|
|
9360
|
+
}
|
|
9361
|
+
],
|
|
9362
|
+
"1.21.89": [
|
|
9363
|
+
{
|
|
9364
|
+
"id": "1004656",
|
|
9365
|
+
"text": "A new project now really runs under the government its founder picks. Before, the choice made while creating a project was stored and then ignored, so every project stayed under one-person rule. Now picking a government files"
|
|
9366
|
+
}
|
|
9355
9367
|
]
|
|
9356
9368
|
}
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
const fs = require('node:fs');
|
|
31
31
|
const path = require('node:path');
|
|
32
32
|
const { spawnSync } = require('node:child_process');
|
|
33
|
+
const crypto = require('node:crypto');
|
|
33
34
|
const { VERBS } = require('../../bin/bongos.js'); // side-effect-free require (guarded main)
|
|
34
35
|
const ic = require('../../src/instance-config'); // neutral configDir/envPrefix fallbacks (never a founder literal)
|
|
35
36
|
const { isCorePackageName, CORE_PACKAGE_NAMES } = require('../../src/module-loader/catalog'); // one definition of "this checkout IS the core"
|
|
@@ -277,6 +278,59 @@ function moduleSkillSources(opts) {
|
|
|
277
278
|
return out;
|
|
278
279
|
}
|
|
279
280
|
|
|
281
|
+
// ---- retired core skills: the tombstone list (task 1004482) -----------------
|
|
282
|
+
// A skill the core USED to ship from its own .claude/skills/ and no longer does (the dev-box
|
|
283
|
+
// verbs, the old /dev /ideate /paint role playbooks, the pixel-art skills that moved into a
|
|
284
|
+
// default-off module). Every instance materialized before task 1004482 received them as core
|
|
285
|
+
// skills, and the provenance manifest then recorded only module-declared names — so step 1c
|
|
286
|
+
// could never prove it had put them there and they stayed in .claude/skills/ for the harness to
|
|
287
|
+
// load and the hall's Commands page to list, forever.
|
|
288
|
+
//
|
|
289
|
+
// From task 1004482 on the manifest records EVERY skill a materialize lands, so a skill the core
|
|
290
|
+
// drops later is withdrawn by step 1c's ordinary rule. This list closes the historical gap only,
|
|
291
|
+
// and is therefore a closed set: it should not need a new entry.
|
|
292
|
+
//
|
|
293
|
+
// "Never touch a skill the instance authored" is held by a fingerprint, not by the name: each
|
|
294
|
+
// entry lists sha256 digests (first 16 hex) of every description the core ever shipped under that
|
|
295
|
+
// name, as written and as bongosify() rewrote it. A dir whose SKILL.md description matches none of
|
|
296
|
+
// them is not ours and is left in place with a note — an owner who wrote their own /dev keeps it.
|
|
297
|
+
const RETIRED_CORE_SKILLS_FILE = path.join(__dirname, 'retired-core-skills.json');
|
|
298
|
+
|
|
299
|
+
// The `description:` value of a SKILL.md's frontmatter, whitespace-collapsed — a plain scalar,
|
|
300
|
+
// a quoted one, or a block scalar (`>` / `|`) with its indented continuation lines. null when
|
|
301
|
+
// the file has no frontmatter or no description. PURE + exported.
|
|
302
|
+
function skillDescription(text) {
|
|
303
|
+
const m = String(text == null ? '' : text).replace(/\r\n/g, '\n').match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
|
|
304
|
+
if (!m) return null;
|
|
305
|
+
const lines = m[1].split('\n');
|
|
306
|
+
const i = lines.findIndex((l) => /^description:/.test(l));
|
|
307
|
+
if (i < 0) return null;
|
|
308
|
+
const parts = [lines[i].replace(/^description:\s*/, '')];
|
|
309
|
+
if (/^[>|][+-]?$/.test(parts[0])) parts[0] = '';
|
|
310
|
+
for (let j = i + 1; j < lines.length && /^\s+\S/.test(lines[j]); j += 1) parts.push(lines[j]);
|
|
311
|
+
const value = parts.join(' ').replace(/\s+/g, ' ').trim().replace(/^(["'])([\s\S]*)\1$/, '$2').trim();
|
|
312
|
+
return value || null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function descriptionDigest(desc) {
|
|
316
|
+
return crypto.createHash('sha256').update(String(desc)).digest('hex').slice(0, 16);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Does this instance skill dir carry a description the core shipped under `name`? Reads the
|
|
320
|
+
// tombstone's digests; an unreadable SKILL.md or a missing description answers "no" (keep it).
|
|
321
|
+
function isRetiredCoreCopy(dir, digests) {
|
|
322
|
+
if (!Array.isArray(digests) || !digests.length) return false;
|
|
323
|
+
let text;
|
|
324
|
+
try { text = fs.readFileSync(path.join(dir, 'SKILL.md'), 'utf8'); } catch { return false; }
|
|
325
|
+
const desc = skillDescription(text);
|
|
326
|
+
return desc != null && digests.includes(descriptionDigest(desc));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function readRetiredCoreSkills(file = RETIRED_CORE_SKILLS_FILE) {
|
|
330
|
+
const j = readJsonSafe(file);
|
|
331
|
+
return j && j.skills && typeof j.skills === 'object' ? j.skills : {};
|
|
332
|
+
}
|
|
333
|
+
|
|
280
334
|
// ---- I/O: copy a tree, transforming text files through bongosify ----------
|
|
281
335
|
|
|
282
336
|
const TEXT_EXT = new Set(['.md', '.txt', '.sh']); // command-bearing text we rewrite (skills are SKILL.md)
|
|
@@ -759,10 +813,38 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
759
813
|
// exists to fix. Deliberately asked of the CORE source tree rather than the roster, so a
|
|
760
814
|
// name no enabled module declares any more is still recognised as legitimately sourced.
|
|
761
815
|
const coreShipsNow = (name) => fs.existsSync(path.join(skillsSrc, name)) && !skipSkills.has(name);
|
|
816
|
+
// Every CORE skill step 1 landed is recorded too (task 1004482), so a skill the core stops
|
|
817
|
+
// shipping in a later release is withdrawn by the rule below instead of lingering forever.
|
|
818
|
+
// When the core's .claude/skills/ could not be read at all, nothing was landed from it and
|
|
819
|
+
// nothing may be withdrawn on that account: the previous core entries are carried forward.
|
|
820
|
+
const coreTreeRead = skillsRes.present;
|
|
821
|
+
if (coreTreeRead) {
|
|
822
|
+
for (const name of fs.readdirSync(skillsSrc)) {
|
|
823
|
+
if (landedNow[name] || !SKILL_NAME_RE.test(name) || skillSkip(name)) continue;
|
|
824
|
+
if (!fs.statSync(path.join(skillsSrc, name)).isDirectory()) continue;
|
|
825
|
+
if (!dryRun && !fs.existsSync(path.join(skillsDest, name))) continue;
|
|
826
|
+
landedNow[name] = { source: `.claude/skills/${name}`, coreShipped: true };
|
|
827
|
+
}
|
|
828
|
+
} else {
|
|
829
|
+
for (const [name, rec] of Object.entries(landedPreviously)) if (rec && rec.coreShipped && !rec.module && !landedNow[name]) landedNow[name] = rec;
|
|
830
|
+
}
|
|
762
831
|
const withdrawable = Object.keys(landedPreviously).filter((n) => SKILL_NAME_RE.test(n) && !landedNow[n] && !coreShipsNow(n));
|
|
832
|
+
// The historical half (task 1004482): a skill the core retired BEFORE the manifest recorded
|
|
833
|
+
// core skills is withdrawn when its dir still carries a description the core shipped under
|
|
834
|
+
// that name. Anything else under a retired name is the instance's own and stays.
|
|
835
|
+
const retired = readRetiredCoreSkills();
|
|
836
|
+
const keptAsOwn = [];
|
|
837
|
+
for (const [name, entry] of Object.entries(retired)) {
|
|
838
|
+
if (!SKILL_NAME_RE.test(name) || landedNow[name] || coreShipsNow(name) || withdrawable.includes(name)) continue;
|
|
839
|
+
const dir = path.join(skillsDest, name);
|
|
840
|
+
if (!fs.existsSync(dir)) continue;
|
|
841
|
+
if (isRetiredCoreCopy(dir, entry && entry.descriptionDigests)) withdrawable.push(name);
|
|
842
|
+
else keptAsOwn.push(name);
|
|
843
|
+
}
|
|
844
|
+
if (keptAsOwn.length) summary.notes.push(`${keptAsOwn.length} skill dir(s) share a name the core once shipped but are not the core's copy (their description is not one it shipped), so they were left in place as the instance's own: ${keptAsOwn.sort().join(', ')}.`);
|
|
763
845
|
if (!overwrite) {
|
|
764
846
|
// adopt mode never clobbers what the owner already has; withdrawing is a clobber.
|
|
765
|
-
if (withdrawable.length) summary.notes.push(`${withdrawable.length} skill(s)
|
|
847
|
+
if (withdrawable.length) summary.notes.push(`${withdrawable.length} skill(s) the core no longer lands here were left in place (adopt mode does not withdraw): ${withdrawable.sort().join(', ')}.`);
|
|
766
848
|
} else {
|
|
767
849
|
for (const name of withdrawable.sort()) {
|
|
768
850
|
const dir = path.join(skillsDest, name);
|
|
@@ -774,7 +856,7 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
774
856
|
if (!dryRun) fs.rmSync(dir, { recursive: true, force: true });
|
|
775
857
|
summary.withdrawnSkills.push(name);
|
|
776
858
|
}
|
|
777
|
-
if (summary.withdrawnSkills.length) log(` ${dryRun ? '[dry-run] would withdraw' : '✓ withdrew'} ${summary.withdrawnSkills.length} stale skill dir(s)
|
|
859
|
+
if (summary.withdrawnSkills.length) log(` ${dryRun ? '[dry-run] would withdraw' : '✓ withdrew'} ${summary.withdrawnSkills.length} stale skill dir(s) the core no longer ships here (retired, or its module is off): ${summary.withdrawnSkills.join(', ')}`);
|
|
778
860
|
}
|
|
779
861
|
if (!Object.keys(landedPreviously).length && summary.excludedSkills.length) {
|
|
780
862
|
summary.notes.push('no .instance-skills.json provenance in .claude/skills/ yet, so nothing was withdrawn — this run records it; a later run can withdraw the skills of a module that has since been disabled.');
|
|
@@ -788,7 +870,7 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
788
870
|
// the pre-flight halts on and the real run then rewrites anyway.
|
|
789
871
|
if (overwrite) {
|
|
790
872
|
const names = Object.keys(landedNow).sort();
|
|
791
|
-
const manifest = { _doc: 'GENERATED by scripts/gds/claude-materialize.js (
|
|
873
|
+
const manifest = { _doc: 'GENERATED by scripts/gds/claude-materialize.js (tasks 1003632, 1004482): every skill a materialize landed in this instance, core and module alike. It is the provenance record that lets a later run WITHDRAW a skill the core stopped shipping or whose module has since been disabled — a name absent from here is never deleted on that account, so a hand-written skill is safe. Commit it; do not hand-edit.', skills: Object.fromEntries(names.map((n) => [n, landedNow[n]])) };
|
|
792
874
|
if (!dryRun) {
|
|
793
875
|
fs.mkdirSync(skillsDest, { recursive: true });
|
|
794
876
|
fs.writeFileSync(path.join(skillsDest, INSTANCE_SKILLS_MANIFEST), JSON.stringify(manifest, null, 2) + '\n');
|
|
@@ -1112,4 +1194,4 @@ if (require.main === module) {
|
|
|
1112
1194
|
process.exit(s.refused.length ? 1 : 0);
|
|
1113
1195
|
}
|
|
1114
1196
|
|
|
1115
|
-
module.exports = { scriptToVerb, bongosify, PROJECT_CLI, topUpNpxAllowTwins, transformSettings, readSettingsTransform, renderStarterSettings, STARTER_ALLOW, copyTreeTransformed, materializeClaude, materializeWillRewrite, materializeModuleSkillsIntoCore, isCoreCheckoutRoot, readModuleSkillsManifest, MODULE_SKILLS_MANIFEST, readInstanceSkillsManifest, INSTANCE_SKILLS_MANIFEST, GDS_INVOCATION_RE, buildRebrandRules, rebrandText, disabledModuleSkills, moduleSkillRoster, moduleSkillSources, vendoredSkillGate, SKILL_NAME_RE, VENDOR_DEFAULT_POLICY };
|
|
1197
|
+
module.exports = { skillDescription, descriptionDigest, isRetiredCoreCopy, readRetiredCoreSkills, RETIRED_CORE_SKILLS_FILE, scriptToVerb, bongosify, PROJECT_CLI, topUpNpxAllowTwins, transformSettings, readSettingsTransform, renderStarterSettings, STARTER_ALLOW, copyTreeTransformed, materializeClaude, materializeWillRewrite, materializeModuleSkillsIntoCore, isCoreCheckoutRoot, readModuleSkillsManifest, MODULE_SKILLS_MANIFEST, readInstanceSkillsManifest, INSTANCE_SKILLS_MANIFEST, GDS_INVOCATION_RE, buildRebrandRules, rebrandText, disabledModuleSkills, moduleSkillRoster, moduleSkillSources, vendoredSkillGate, SKILL_NAME_RE, VENDOR_DEFAULT_POLICY };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_doc": "Tombstone list (task 1004482): skills the core once shipped from its own .claude/skills/ and no longer does, from before materialize recorded core skills in an instance's .instance-skills.json. Each lists sha256 digests (first 16 hex) of every description the core ever shipped under that name, as written and as bongosify() rewrote it; claude-materialize.js withdraws an instance dir under one of these names ONLY when its SKILL.md description matches, so a skill the instance wrote itself is never touched. A closed historical set: a skill the core drops from now on is withdrawn through the manifest, with no entry here.",
|
|
3
|
+
"skills": {
|
|
4
|
+
"builder-box": {
|
|
5
|
+
"descriptionDigests": [
|
|
6
|
+
"14913228d8aefa2a",
|
|
7
|
+
"3a8458946af072fb",
|
|
8
|
+
"7695811a3bb4fa8a",
|
|
9
|
+
"d77e96d673b77f5b"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"builder-connect": {
|
|
13
|
+
"descriptionDigests": [
|
|
14
|
+
"27137471966e6a98",
|
|
15
|
+
"2bc0f02001bf7311",
|
|
16
|
+
"5bfb4795ceb3e77a",
|
|
17
|
+
"a9442b55f9588a06",
|
|
18
|
+
"d661c46a8076a1ea"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"builder-sync": {
|
|
22
|
+
"descriptionDigests": [
|
|
23
|
+
"3fad042c07a1d8e7",
|
|
24
|
+
"50c1a8505aead99b"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"close-box": {
|
|
28
|
+
"descriptionDigests": [
|
|
29
|
+
"03d839f2e5909448",
|
|
30
|
+
"4f30758956f46d88",
|
|
31
|
+
"66aef8299b4c39af",
|
|
32
|
+
"b3f6743fdb4a07fe",
|
|
33
|
+
"d119bb63ffaba1e8"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"dev": {
|
|
37
|
+
"descriptionDigests": [
|
|
38
|
+
"7836b393796e9ee0",
|
|
39
|
+
"8cf08a65be57d5f1"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
"feedback": {
|
|
43
|
+
"descriptionDigests": [
|
|
44
|
+
"211c55ddd59ef937",
|
|
45
|
+
"2f58da0a75816b09",
|
|
46
|
+
"3b48dc4cf9e43692",
|
|
47
|
+
"7bca081fc8092577",
|
|
48
|
+
"85bb6d1200234a28",
|
|
49
|
+
"a309ad6d9ea56a5f",
|
|
50
|
+
"ab21803079a7530c",
|
|
51
|
+
"e090c86c838c0f97"
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
"ideate": {
|
|
55
|
+
"descriptionDigests": [
|
|
56
|
+
"466396868c0d2d37"
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
"otb-character-review": {
|
|
60
|
+
"descriptionDigests": [
|
|
61
|
+
"518356210d594d2e",
|
|
62
|
+
"af216311f26edffd",
|
|
63
|
+
"eb529abb98348cd1"
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
"otb-design-review": {
|
|
67
|
+
"descriptionDigests": [
|
|
68
|
+
"1b1375bc9ea12faa",
|
|
69
|
+
"9f97a32f15c805bc"
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
"otb-feedback-capture": {
|
|
73
|
+
"descriptionDigests": [
|
|
74
|
+
"6ba86ab812dd9495",
|
|
75
|
+
"c915f0de7ab1e8fd"
|
|
76
|
+
]
|
|
77
|
+
},
|
|
78
|
+
"otb-figma-sync": {
|
|
79
|
+
"descriptionDigests": [
|
|
80
|
+
"281987db3241a779",
|
|
81
|
+
"61132c40a0388319"
|
|
82
|
+
]
|
|
83
|
+
},
|
|
84
|
+
"otb-tile-generate": {
|
|
85
|
+
"descriptionDigests": [
|
|
86
|
+
"57981088137a89ee",
|
|
87
|
+
"b1db07959665a454"
|
|
88
|
+
]
|
|
89
|
+
},
|
|
90
|
+
"paint": {
|
|
91
|
+
"descriptionDigests": [
|
|
92
|
+
"944ae7795b04d726"
|
|
93
|
+
]
|
|
94
|
+
},
|
|
95
|
+
"tweak": {
|
|
96
|
+
"descriptionDigests": [
|
|
97
|
+
"a8d84a40f2e3d8a2"
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
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.21.
|
|
78
|
+
const CORE_VERSION = '1.21.89'; // 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');
|
|
@@ -19,6 +19,7 @@ import { strict as assert } from 'node:assert';
|
|
|
19
19
|
import { createRequire } from 'node:module';
|
|
20
20
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from 'node:fs';
|
|
21
21
|
import { tmpdir, homedir } from 'node:os';
|
|
22
|
+
import { execFileSync } from 'node:child_process';
|
|
22
23
|
import { join, dirname, relative, sep } from 'node:path';
|
|
23
24
|
import { fileURLToPath } from 'node:url';
|
|
24
25
|
|
|
@@ -420,6 +421,87 @@ t('materializeClaude: --dry-run reports the withdrawal without performing it; ad
|
|
|
420
421
|
for (const d of [core, inst]) rmSync(d, { recursive: true, force: true });
|
|
421
422
|
});
|
|
422
423
|
|
|
424
|
+
// ---- withdrawing skills the CORE stopped shipping (task 1004482) ----------------
|
|
425
|
+
// The bug: the manifest recorded only module-declared skills, so a skill the core shipped
|
|
426
|
+
// from its own .claude/skills/ and later dropped (the dev-box verbs, /dev /ideate /paint,
|
|
427
|
+
// the pixel-art skills) stayed in every upgraded instance, loaded by the harness and listed
|
|
428
|
+
// on the hall's Commands page. Two halves: the manifest now records core skills (future
|
|
429
|
+
// retirements), and a tombstone list fingerprinted by description covers the past ones.
|
|
430
|
+
|
|
431
|
+
// The /ideate description the core shipped until 2026-09-09, verbatim — a block scalar.
|
|
432
|
+
const IDEATE_SHIPPED = '---\nname: ideate\ndescription: >-\n The ideation session — the operating playbook for ideator-discipline work. Triggers when the user says "/ideate", "let\'s ideate", "I want to brainstorm", "help me think through an idea", "start an ideation session", or when a claimed task\'s discipline routes here (claim.js prints a directive to invoke /ideate for ideator tasks). Inverts the usual dynamic: the human ideator drives, Claude is the sounding board. Open to any rank for FILING ideas (Xenos+ can capture; only Metic+ can triage).\n---\nbody\n';
|
|
433
|
+
|
|
434
|
+
function coreSkillFixture() {
|
|
435
|
+
const core = mkdtempSync(join(tmpdir(), 'rt-core-'));
|
|
436
|
+
const inst = mkdtempSync(join(tmpdir(), 'rt-inst-'));
|
|
437
|
+
for (const n of ['keep', 'gamma']) {
|
|
438
|
+
mkdirSync(join(core, '.claude', 'skills', n), { recursive: true });
|
|
439
|
+
writeFileSync(join(core, '.claude', 'skills', n, 'SKILL.md'), `---\nname: ${n}\ndescription: ${n} skill\n---\nbody\n`);
|
|
440
|
+
}
|
|
441
|
+
return { core, inst };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
t('skillDescription: reads plain, quoted and block-scalar descriptions; null without one', () => {
|
|
445
|
+
assert.equal(m.skillDescription('---\nname: a\ndescription: one line\n---\n'), 'one line');
|
|
446
|
+
assert.equal(m.skillDescription('---\r\nname: a\r\ndescription: "quoted"\r\n---\r\n'), 'quoted');
|
|
447
|
+
assert.equal(m.skillDescription('---\ndescription: >-\n folded\n over two\nname: a\n---\n'), 'folded over two');
|
|
448
|
+
assert.equal(m.skillDescription('---\nname: a\n---\n'), null);
|
|
449
|
+
assert.equal(m.skillDescription('no frontmatter'), null);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
t('materializeClaude: a core skill the core STOPPED shipping is withdrawn on the next upgrade (task 1004482)', () => {
|
|
453
|
+
const { core, inst } = coreSkillFixture();
|
|
454
|
+
m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
455
|
+
const mani = m.readInstanceSkillsManifest(join(inst, '.claude', 'skills'));
|
|
456
|
+
assert.ok(mani.skills.gamma && mani.skills.gamma.coreShipped, 'core skills are recorded as landed now');
|
|
457
|
+
rmSync(join(core, '.claude', 'skills', 'gamma'), { recursive: true, force: true });
|
|
458
|
+
const res = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
459
|
+
assert.deepEqual(res.withdrawnSkills, ['gamma']);
|
|
460
|
+
assert.ok(!existsSync(join(inst, '.claude', 'skills', 'gamma')), 'the retired core skill is gone');
|
|
461
|
+
assert.ok(existsSync(join(inst, '.claude', 'skills', 'keep')), 'a skill the core still ships stays');
|
|
462
|
+
assert.ok(res.writtenPaths.includes('.claude/skills/gamma/SKILL.md'), 'the deletion is reported so the pin commit carries it');
|
|
463
|
+
for (const d of [core, inst]) rmSync(d, { recursive: true, force: true });
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
t('materializeClaude: a core with no readable .claude/skills/ withdraws no core skill (task 1004482)', () => {
|
|
467
|
+
const { core, inst } = coreSkillFixture();
|
|
468
|
+
m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
469
|
+
rmSync(join(core, '.claude', 'skills'), { recursive: true, force: true });
|
|
470
|
+
const res = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
471
|
+
assert.deepEqual(res.withdrawnSkills, [], 'nothing landed from the core, so nothing is withdrawn on that account');
|
|
472
|
+
assert.ok(existsSync(join(inst, '.claude', 'skills', 'gamma')));
|
|
473
|
+
assert.ok(m.readInstanceSkillsManifest(join(inst, '.claude', 'skills')).skills.gamma, 'and the provenance is carried forward');
|
|
474
|
+
for (const d of [core, inst]) rmSync(d, { recursive: true, force: true });
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
t('materializeClaude: a RETIRED core skill with the description the core shipped is withdrawn; one the instance wrote under that name is kept (task 1004482)', () => {
|
|
478
|
+
const { core, inst } = coreSkillFixture();
|
|
479
|
+
const skills = join(inst, '.claude', 'skills');
|
|
480
|
+
// an instance materialized before core skills were recorded: no manifest names either dir
|
|
481
|
+
mkdirSync(join(skills, 'ideate'), { recursive: true });
|
|
482
|
+
writeFileSync(join(skills, 'ideate', 'SKILL.md'), IDEATE_SHIPPED);
|
|
483
|
+
mkdirSync(join(skills, 'dev'), { recursive: true });
|
|
484
|
+
writeFileSync(join(skills, 'dev', 'SKILL.md'), '---\nname: dev\ndescription: our own dev loop\n---\nMINE\n');
|
|
485
|
+
const res = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
486
|
+
assert.deepEqual(res.withdrawnSkills, ['ideate'], 'the core\'s old /ideate goes');
|
|
487
|
+
assert.ok(!existsSync(join(skills, 'ideate')));
|
|
488
|
+
assert.equal(readFileSync(join(skills, 'dev', 'SKILL.md'), 'utf8'), '---\nname: dev\ndescription: our own dev loop\n---\nMINE\n', 'the owner\'s /dev is untouched');
|
|
489
|
+
assert.ok(res.notes.some((n) => /left in place as the instance's own: dev/.test(n)), 'and the run says why it kept it');
|
|
490
|
+
for (const d of [core, inst]) rmSync(d, { recursive: true, force: true });
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
t('retired-core-skills.json: names the dev-box verbs, the old role playbooks and the pixel-art skills, none of which the core ships now', () => {
|
|
494
|
+
const retired = m.readRetiredCoreSkills();
|
|
495
|
+
for (const n of ['builder-box', 'builder-connect', 'builder-sync', 'close-box', 'dev', 'ideate', 'paint', 'otb-tile-generate', 'otb-design-review']) {
|
|
496
|
+
assert.ok(retired[n] && retired[n].descriptionDigests.length, `${n} is tombstoned with at least one digest`);
|
|
497
|
+
}
|
|
498
|
+
// A tombstone on a name the core still ships would be inert (step 1 lands it), but it would
|
|
499
|
+
// also mean the list was built wrong — tracked files only, since a checkout holds untracked
|
|
500
|
+
// module-skill copies (ADR 0224) under the same directory.
|
|
501
|
+
const tracked = execFileSync('git', ['-C', ROOT, 'ls-files', '.claude/skills'], { encoding: 'utf8' });
|
|
502
|
+
for (const n of Object.keys(retired)) assert.ok(!tracked.includes(`.claude/skills/${n}/`), `${n} is not a skill the core ships from .claude/skills/`);
|
|
503
|
+
});
|
|
504
|
+
|
|
423
505
|
// ---- module-owned skills (task 1003321 / ADR 0198) ---------------------------
|
|
424
506
|
// A module ships a skill from modules/<key>/skills/<name>/ (its own) or
|
|
425
507
|
// modules/<key>/skills/vendor/<name>/ (third-party + PROVENANCE.md); only a DECLARED
|