@bongos/core 1.21.87 → 1.21.88

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 CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.21.87",
6
- "core_contract": "1.21.87",
7
- "source_commit": "3a000da66e4b7ed910b59eb0e1843e81a7e3f7fd",
5
+ "core_version": "1.21.88",
6
+ "core_contract": "1.21.88",
7
+ "source_commit": "4bfba51c57c0f602ad9c166e149b64f3440e88b9",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-09T05:06:35.756Z",
9
+ "built_at": "2026-10-09T05:24:37.080Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 590,
13
13
  "agent_docs_stubbed": 28,
14
- "functional_verbatim": 3000,
14
+ "functional_verbatim": 3001,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3619,
20
- "tree_sha256": "94cb33a270055b9c3e008a1a26ad2e66038572aae76148fccb2f9b3bf2bbecf9",
19
+ "file_count": 3620,
20
+ "tree_sha256": "9c2cd52bac9f184d7097d5da786d1a69fe8403ddf698d36b914c4b39da21eeb7",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2862,7 +2862,7 @@
2862
2862
  {
2863
2863
  "path": "docs/module-api-changelog.md",
2864
2864
  "mode": "0000644",
2865
- "sha256": "39126044930a9015689ba736100232d99d1d13d43d723ec95a15dbe505c4390d"
2865
+ "sha256": "ada2da687d2c0d7dbb14e027ab755e6fbd771ff026e48d62aeda44b5d14fa923"
2866
2866
  },
2867
2867
  {
2868
2868
  "path": "docs/modules-contract.md",
@@ -10097,12 +10097,12 @@
10097
10097
  {
10098
10098
  "path": "package-lock.json",
10099
10099
  "mode": "0000644",
10100
- "sha256": "6953a80922eac656bc533d17e921f49228e939cacb64da8e56d1a90fa5f358db"
10100
+ "sha256": "cfe8ee2b06d54070a847c417dc95352c7817ef56bf8a49e4ffe0d0bce8615c26"
10101
10101
  },
10102
10102
  {
10103
10103
  "path": "package.json",
10104
10104
  "mode": "0000644",
10105
- "sha256": "be88b7b575574dc07cbf071c639c419a4365cd89e7ed0b7bba058de98982b443"
10105
+ "sha256": "50698c5b17011dbaf2098ae3bdcef8540a946ab36e2d5555c824666f778287ae"
10106
10106
  },
10107
10107
  {
10108
10108
  "path": "public-docs/index.html",
@@ -10122,7 +10122,7 @@
10122
10122
  {
10123
10123
  "path": "release-notes.json",
10124
10124
  "mode": "0000644",
10125
- "sha256": "ba7ae503bc8f34ef8244b2fb989462283e4128b6ea03ac066d050acfb35edbc5"
10125
+ "sha256": "7d25584e81384714b841f849588e0ed95a630ed13d2adc5b106d9bc2940b5728"
10126
10126
  },
10127
10127
  {
10128
10128
  "path": "scripts/cron/style_guide_audit.sh",
@@ -10427,7 +10427,7 @@
10427
10427
  {
10428
10428
  "path": "scripts/gds/claude-materialize.js",
10429
10429
  "mode": "0000644",
10430
- "sha256": "3dbededc4c7c82f844bc772ef10a9aefdf73bc3b1357db70177d164c7e2be87e"
10430
+ "sha256": "1d141943f8367b8814e5a455c0c5d68a3915ac802df6cae9c3550f0628dbc384"
10431
10431
  },
10432
10432
  {
10433
10433
  "path": "scripts/gds/claude-md-template.js",
@@ -11304,6 +11304,11 @@
11304
11304
  "mode": "0000644",
11305
11305
  "sha256": "084fa5c4da1e755fa0edde5ec770e5151a9f66a247bca222bc7a2366ccf61748"
11306
11306
  },
11307
+ {
11308
+ "path": "scripts/gds/retired-core-skills.json",
11309
+ "mode": "0000644",
11310
+ "sha256": "74dda013c4482bcd6a42bcfb36d0affa435044e7981a3c98c9e861f99ba3ade2"
11311
+ },
11307
11312
  {
11308
11313
  "path": "scripts/gds/review-sheet.js",
11309
11314
  "mode": "0000644",
@@ -12377,7 +12382,7 @@
12377
12382
  {
12378
12383
  "path": "src/module-api.js",
12379
12384
  "mode": "0000644",
12380
- "sha256": "3ba47cbdc5c1c0dc86af2cf1302a6ffc3c9966a7646f88bd2a004f5dd67b0ded"
12385
+ "sha256": "5408eebecc30a0da2527855d9aa1aff7b66658f32b504f4e71517938ad7dcb83"
12381
12386
  },
12382
12387
  {
12383
12388
  "path": "src/module-loader/catalog.js",
@@ -13162,7 +13167,7 @@
13162
13167
  {
13163
13168
  "path": "tests/claude_materialize.mjs",
13164
13169
  "mode": "0000644",
13165
- "sha256": "bf30601c49fb27cc0b230e6f48e4ed75af68c9cb6b8e8b5f7e13b38eb0241eec"
13170
+ "sha256": "da076e811015020cb8a7cc730dc8f403d1b9e29749871078998ebca64113b0e5"
13166
13171
  },
13167
13172
  {
13168
13173
  "path": "tests/clawback_confirmed_backlog.mjs",
@@ -3006,5 +3006,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
3006
3006
  landed since 1.21.85 with no explicit bump. run 37885516955. (task 1002620)
3007
3007
  1.21.87 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3008
3008
  landed since 1.21.86 with no explicit bump. run 37886940909. (task 1002620)
3009
+ 1.21.88 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3010
+ landed since 1.21.87 with no explicit bump. run 37888371878. (task 1002620)
3009
3011
  ---------------------------------------------------------------------------
3010
3012
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.87",
3
+ "version": "1.21.88",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.87",
9
+ "version": "1.21.88",
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.87",
3
+ "version": "1.21.88",
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",
@@ -9352,5 +9352,11 @@
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
+ }
9355
9361
  ]
9356
9362
  }
@@ -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) of a now-disabled module were left in place (adopt mode does not withdraw): ${withdrawable.sort().join(', ')}.`);
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) whose module is no longer enabled: ${summary.withdrawnSkills.join(', ')}`);
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 (task 1003632): the module-declared skills a materialize landed in this instance. It is the provenance record that lets a later run WITHDRAW a skill whose module has since been disabled — a name absent from here is never deleted, so a hand-written skill is safe. Commit it; do not hand-edit.', skills: Object.fromEntries(names.map((n) => [n, landedNow[n]])) };
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.87'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.88'; // 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