@bongos/core 1.21.86 → 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.86",
6
- "core_contract": "1.21.86",
7
- "source_commit": "7183f6e501a4de2ea256ffa4c2d19ccc62d7fdc1",
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-09T04:48:08.126Z",
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": 2998,
14
+ "functional_verbatim": 3001,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3617,
20
- "tree_sha256": "391c304f1f86515a2c812e6764e83ba98b5fe6a67757909baf15d8e88009694b",
19
+ "file_count": 3620,
20
+ "tree_sha256": "9c2cd52bac9f184d7097d5da786d1a69fe8403ddf698d36b914c4b39da21eeb7",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2432,7 +2432,7 @@
2432
2432
  {
2433
2433
  "path": "docs/design/project-startup-direction.md",
2434
2434
  "mode": "0000644",
2435
- "sha256": "8b95dbb81af5cad9a85d3ffe9532d60391329cc957d5c79327e187bbfb3551de"
2435
+ "sha256": "8cac194bfa4e7e0cfa6785ff72fd141980eb2abed6497ce56e42585c2f4a918c"
2436
2436
  },
2437
2437
  {
2438
2438
  "path": "docs/design/projects-hub-direction-v2.md",
@@ -2862,7 +2862,7 @@
2862
2862
  {
2863
2863
  "path": "docs/module-api-changelog.md",
2864
2864
  "mode": "0000644",
2865
- "sha256": "007f5683bb299be37f78f220958ae14bb8a8069fc59b81cc6fb94a12c5b3712f"
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": "9d3ecc5f558e51dc8e4e66a6f3f0a6e9773e517ce9571ae4ec130b33128536ff"
10100
+ "sha256": "cfe8ee2b06d54070a847c417dc95352c7817ef56bf8a49e4ffe0d0bce8615c26"
10101
10101
  },
10102
10102
  {
10103
10103
  "path": "package.json",
10104
10104
  "mode": "0000644",
10105
- "sha256": "ce90ad007650b7bcf0705bf0743acf66507496e0a043d7d6c2d9b84795b94f9b"
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": "99cc3e08c1384339c868c499061c081eade1bcb0a996c47d3e644066d22ca536"
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",
@@ -11734,10 +11739,15 @@
11734
11739
  "mode": "0000644",
11735
11740
  "sha256": "9f6ce80e5325f4cfe68cfa6cb82bec52d594af7e461b6734724819b23cbb0d37"
11736
11741
  },
11742
+ {
11743
+ "path": "scripts/gds/start-goals.js",
11744
+ "mode": "0000644",
11745
+ "sha256": "8c4e1f1c7871c82b0fefd5402c93888831543dcd776a1e761e7b1238b3afafaa"
11746
+ },
11737
11747
  {
11738
11748
  "path": "scripts/gds/start.js",
11739
11749
  "mode": "0000644",
11740
- "sha256": "4bc438bb7859c7a405088b2e386b33fb550532a71dbe66866d711e5b1d4b3e69"
11750
+ "sha256": "181f07b8d62f03ae001d08dd5e5b15a65b33f512fb548e55401b97f7f484f31f"
11741
11751
  },
11742
11752
  {
11743
11753
  "path": "scripts/gds/status.js",
@@ -12372,7 +12382,7 @@
12372
12382
  {
12373
12383
  "path": "src/module-api.js",
12374
12384
  "mode": "0000644",
12375
- "sha256": "2fd52ee0afe6fc9ade992a558ebb0fc3ad5904f876f7c5093dd60ea5d201b448"
12385
+ "sha256": "5408eebecc30a0da2527855d9aa1aff7b66658f32b504f4e71517938ad7dcb83"
12376
12386
  },
12377
12387
  {
12378
12388
  "path": "src/module-loader/catalog.js",
@@ -13157,7 +13167,7 @@
13157
13167
  {
13158
13168
  "path": "tests/claude_materialize.mjs",
13159
13169
  "mode": "0000644",
13160
- "sha256": "bf30601c49fb27cc0b230e6f48e4ed75af68c9cb6b8e8b5f7e13b38eb0241eec"
13170
+ "sha256": "da076e811015020cb8a7cc730dc8f403d1b9e29749871078998ebca64113b0e5"
13161
13171
  },
13162
13172
  {
13163
13173
  "path": "tests/clawback_confirmed_backlog.mjs",
@@ -17529,6 +17539,11 @@
17529
17539
  "mode": "0000644",
17530
17540
  "sha256": "39e62a5fc50113e719cdaacf15e6fd054f38f8703fc64137eddee7901b374f16"
17531
17541
  },
17542
+ {
17543
+ "path": "tests/start_goal_first.mjs",
17544
+ "mode": "0000644",
17545
+ "sha256": "a3addba1547bf0a9ce95f5d6c2ea676f63495669154e2f4c161cb8cc543b30bf"
17546
+ },
17532
17547
  {
17533
17548
  "path": "tests/start_inbox_count.mjs",
17534
17549
  "mode": "0000644",
@@ -10,7 +10,64 @@ This document is what every build task in goal 1000121 cites. Name a screen by i
10
10
  - **what the new design ADDS**;
11
11
  - the decisions that govern both.
12
12
 
13
- ## The blended flow — supersedes the hub-side screens (owner, 2026-10-01)
13
+ ## One-page flow — supersedes the blended six screens (owner, 2026-10-08)
14
+
15
+ > *Summary wins, do it in the night-room look, if it's day time let's make a day view, and likewise, same for the artist page.* Then: *approved.* — the owner, 2026-10-08.
16
+
17
+ The owner found the six-screen flow still overcomplicated, and the GitHub steps after Create still failing. The mockup in [`mocks/day-night-rooms/`](mocks/day-night-rooms/README.md) is now the design of record for **every hub-side creation screen and for the artist studio's room**. Where it disagrees with the blended flow below, the mockup wins. Genesis (part 2) and the blend rule are unchanged.
18
+
19
+ ### The owner's decisions
20
+
21
+ 1. **One page, three parts.** Sign-in stays the gate in front. Then one page:
22
+ - **01 Your project:** name, kind, the optional sentence, and the four quick checks (country, anyone under 18, where builders will be, who's behind it). The screening stop still fires before anything is created.
23
+ - **02 How it's run:** a summary to skim. Each row (code, address, team, extras, rewards, hosting, we run, look) has a **Change** link that opens the real control in place.
24
+ - **03 Connect GitHub:** opens once the project exists, below **Create my project**.
25
+ 2. **The summary wins.** No row needs confirming and Create is not gated on the rows. This **replaces** the blended flow's "the review must not be skippable" rule. The terms stay a required checkbox above Create, and the terms version stamp still records the acceptance.
26
+ 3. **GitHub in one trip (option A).** One click: approve the project's app on GitHub, go straight on to installing it on the repo, and land back on the page. A checklist ticks itself from what the server has verified (approve the app · install it on your repo · builders can sign in · command-line sign-in is on). Coming back works from any tab or device. The founder chooses a personal or an organisation account. A failure says what stopped, and "Pick up where it stopped" resumes without making a second app.
27
+ 4. **Day room and night room, by the viewer's own clock.** Day from 07:00 to 18:59 local time, night otherwise. The day room is the same drawing in daylight: a sunny sky with clouds, a sun for the moon, lighter towers with unlit windows, a warm wall and floor, the lamp off and a patch of sun on the floor. Its palette is the mockup's `[data-sky="day"]` tokens. **The artist studio gets the same day room**, and its greeting follows the time of day.
28
+ 5. **The demo is a small link** under the heading, not its own screen.
29
+
30
+ ### Nothing the live wizard does is lost
31
+
32
+ This table replaces the blended flow's table below for every hub-side capability. The mockup draws the simple path; each row's capability is a reveal or sits behind a summary row's **Change**.
33
+
34
+ | Today | In the one-page flow |
35
+ |---|---|
36
+ | Q1 new or existing, and what already exists | Part 02's Code row: "A repo I already have" reveals what already exists and the adopt path. |
37
+ | Adopt: GitHub repo picker, manual fallback, conflict pre-flight, backlog import | Behind the Code row's Change, unchanged. |
38
+ | Q2 geography | Part 01's "Where are you?", with state or province revealed beside the country for the countries that need it, and "Where will builders be?". |
39
+ | Q3 under-18s and the age tiers | Part 01; "Yes" reveals the tiers and the guardian rules in place. |
40
+ | Q4 entity, new intent or existing legal type | Part 01's "Who's behind it?"; a company or nonprofit reveals the full entity list. |
41
+ | The screening stop | Unchanged: an answer the platform cannot serve stops the page before Create, with the reason in place. |
42
+ | Q5 one or many, count, growth guess | Part 02's Team row; Change opens the count, the growth guess and how people join. |
43
+ | Q6 rewards | Part 02's Rewards row (pick several). |
44
+ | Q7 framing A–D and the hybrid suite | Part 02's **We run** row ("Everything, for you" by default); "Some of it" reveals the service checklist. |
45
+ | Auto-fill from my account | Kept, as an offer at the top of part 01 when the account has a last project. |
46
+ | Resume a saved draft | Kept, as an offer at the top of the page (there is no fork screen any more). |
47
+ | Type and About | Part 01's Kind and "In a sentence". |
48
+ | Modules picker and search | Part 02's Extras row; Change opens the searchable picker. |
49
+ | Address: handle, own domain, no address | Part 02's Address row (PS19: shown on every path, the demo included). |
50
+ | App: Render key check, workspace, runtime, commands, plan, database | Part 02's Hosting row; Render opens today's settings in place. |
51
+ | Look, logo, night tint | Part 02's Look row; Change opens the look picker, the logo and the night tint. |
52
+ | Terms acknowledgement | The required checkbox above Create; the terms version stamp still records it. |
53
+ | Review | Part 02 itself, plus the one-line recap beside Create. Nothing is gated on confirming rows. |
54
+ | Done: standup strip, the owner-only steps, invite | Part 03 on the same page: the GitHub checklist replaces the paged owner-only steps, then "is alive" with the invite box (once the hall is live, PS15) and the genesis door. |
55
+ | Demo: fit check, settings, teammates, 30-day notice | The "Try a small demo instead" link opens the demo's people, hours, idea and teammates in place at the top of part 01, marks the page "demo", and shows the fit sentence; the rest of the page is unchanged. |
56
+
57
+ ### Who builds it
58
+
59
+ | Task | Builds |
60
+ |---|---|
61
+ | task 1004647 | This record and the mockup. |
62
+ | task 1004648 | The one page, the day room on it, and removing the old panels. |
63
+ | task 1004649 | GitHub in one trip, with the self-ticking checklist. |
64
+ | task 1004650 | The hand-back from any tab or device, and checking while the page is hidden. |
65
+ | task 1004651 | A personal or organisation account for the app. |
66
+ | task 1004652 | Resuming a stopped step, and a fixing button for every failure. |
67
+ | task 1004653 | The end-to-end walk, day and night, desktop and phone. |
68
+ | task 1004654 | The artist studio's day room and time-of-day greeting. |
69
+
70
+ ## The blended flow — superseded by the one-page flow above (owner, 2026-10-01)
14
71
 
15
72
  > *Go for this as the design of record.* — the owner, approving the blended mockup (task 1004515).
16
73
 
@@ -3004,5 +3004,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
3004
3004
  landed since 1.21.84 with no explicit bump. run 37884200093. (task 1002620)
3005
3005
  1.21.86 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3006
3006
  landed since 1.21.85 with no explicit bump. run 37885516955. (task 1002620)
3007
+ 1.21.87 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
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)
3007
3011
  ---------------------------------------------------------------------------
3008
3012
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.86",
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.86",
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.86",
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",
@@ -9342,5 +9342,21 @@
9342
9342
  "id": "1004399",
9343
9343
  "text": "Published module packages are now kept in the project's own data folder instead of inside the installed core, so a core upgrade no longer deletes them; any already stored in the old place are moved over when the server starts."
9344
9344
  }
9345
+ ],
9346
+ "1.21.87": [
9347
+ {
9348
+ "id": "1004647",
9349
+ "text": "The new project creation design is now on record for every builder: one page in three parts instead of seven screens, a skimmable summary instead of confirming every row, GitHub in a single trip with a checklist that ticks its"
9350
+ },
9351
+ {
9352
+ "id": "1002852",
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
+ }
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
+ }
9345
9361
  ]
9346
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
+ }
@@ -0,0 +1,102 @@
1
+ // scripts/gds/start-goals.js — /builder-start, goal-first (task 1002852).
2
+ //
3
+ // WHY. Work is organised in goals, but /builder-start opened on a flat list of
4
+ // tasks, so a builder saw "task 1002852" and never which goal it served or which of
5
+ // their own goals had work waiting. This leads with the goals the builder has
6
+ // JOINED, then shows the same claimable table grouped under each task's goal.
7
+ //
8
+ // The owner's two rules (2026-10-08, WA6 chain kickoff):
9
+ // - the goals sit ABOVE the claimable table and never replace it, so nothing
10
+ // claimable is hidden from a builder who has joined no goal;
11
+ // - a task with no goal is listed under "Other work". Nothing is backfilled.
12
+ //
13
+ // PURE: no I/O. start.js fetches GET /goals (best-effort: a failed read groups by
14
+ // goal id with a generic name and lists no joined goals) and hands both lists in.
15
+
16
+ 'use strict';
17
+
18
+ const OTHER_WORK = 'Other work';
19
+ const JOINED_SHOWN = 5;
20
+
21
+ // The open goals this builder is a member of, in the board's own order (version,
22
+ // sort order). `viewer_is_member` is the server's answer, never re-derived here.
23
+ function joinedGoals(goals) {
24
+ return (Array.isArray(goals) ? goals : [])
25
+ .filter((g) => g && g.viewer_is_member === true && g.status === 'open');
26
+ }
27
+
28
+ function goalName(id, byId) {
29
+ const g = byId.get(String(id));
30
+ return (g && g.title) || `Goal ${id}`;
31
+ }
32
+
33
+ // Group claimable rows by goal, keeping each row's place in the server's order
34
+ // (priority, then estimate) inside its group. Groups run: the builder's joined
35
+ // goals first (board order), then every other goal by its first row, then
36
+ // "Other work" last. Every row lands in exactly one group, so the grouped table
37
+ // lists precisely the rows the flat one did.
38
+ function groupByGoal(rows, goals) {
39
+ const list = Array.isArray(rows) ? rows : [];
40
+ const byId = new Map((Array.isArray(goals) ? goals : []).map((g) => [String(g.id), g]));
41
+ const joinedOrder = joinedGoals(goals).map((g) => String(g.id));
42
+ const groups = new Map();
43
+ for (const t of list) {
44
+ const key = t.goal_id == null ? null : String(t.goal_id);
45
+ if (!groups.has(key)) {
46
+ groups.set(key, {
47
+ goal_id: key,
48
+ title: key == null ? OTHER_WORK : goalName(key, byId),
49
+ joined: key != null && joinedOrder.includes(key),
50
+ rows: [],
51
+ });
52
+ }
53
+ groups.get(key).rows.push(t);
54
+ }
55
+ const all = [...groups.values()];
56
+ const joined = joinedOrder.map((id) => groups.get(id)).filter(Boolean);
57
+ const others = all.filter((g) => g.goal_id != null && !g.joined);
58
+ const other = groups.get(null);
59
+ return [...joined, ...others, ...(other ? [other] : [])];
60
+ }
61
+
62
+ // The "Your goals" lines above the table: each joined goal and how many tasks in
63
+ // it are claimable for this builder right now (from the FULL filtered set, not
64
+ // the capped card rows, so a count never under-reports). Empty when the builder
65
+ // has joined no open goal; the table below still lists everything.
66
+ function yourGoals(goals, claimable) {
67
+ const counts = new Map();
68
+ for (const t of Array.isArray(claimable) ? claimable : []) {
69
+ if (t.goal_id == null) continue;
70
+ const k = String(t.goal_id);
71
+ counts.set(k, (counts.get(k) || 0) + 1);
72
+ }
73
+ const joined = joinedGoals(goals);
74
+ return {
75
+ goals: joined.slice(0, JOINED_SHOWN).map((g) => ({
76
+ id: String(g.id),
77
+ title: g.title || `Goal ${g.id}`,
78
+ claimable: counts.get(String(g.id)) || 0,
79
+ })),
80
+ more: Math.max(0, joined.length - JOINED_SHOWN),
81
+ };
82
+ }
83
+
84
+ // Goal-first ordering for the capped card: rows in a goal the builder joined come
85
+ // first, everything else after, each half keeping the server's order. Without it
86
+ // the card's top rows were the board's top rows and a builder's own goals could
87
+ // show "5 to claim" above a table holding none of them. A stable partition, so it
88
+ // only moves rows, never drops one.
89
+ function joinedFirst(rows, goals) {
90
+ const list = Array.isArray(rows) ? rows : [];
91
+ const joined = new Set(joinedGoals(goals).map((g) => String(g.id)));
92
+ const mine = [];
93
+ const rest = [];
94
+ for (const t of list) (t.goal_id != null && joined.has(String(t.goal_id)) ? mine : rest).push(t);
95
+ return mine.concat(rest);
96
+ }
97
+
98
+ function claimableWord(n) {
99
+ return n === 0 ? 'nothing to claim right now' : `${n} to claim`;
100
+ }
101
+
102
+ module.exports = { OTHER_WORK, joinedGoals, joinedFirst, groupByGoal, yourGoals, claimableWord };
@@ -233,23 +233,30 @@ function whatItIs(t) {
233
233
  // Ids are emitted as hall links (renders as a clean #N) — a bare "#NNN" in a
234
234
  // relayed chat message autolinks to GitHub and 404s (CLAUDE.md ref rule).
235
235
  const surface = require('./surface');
236
+ const startGoals = require('./start-goals');
236
237
  const HALL_TASK = `${hallBase()}#/task/`;
237
- function claimableTable(rows, rewardFromServer) {
238
+ function claimableTable(rows, rewardFromServer, goals) {
238
239
  // On a cost-plus-only instance the Reward column is an advertised estimate that
239
240
  // is never paid — say so in the header so the column can't read as a promise,
240
241
  // and footnote what IS paid below the table (task 1002469).
241
242
  const perTask = rewardPolicy(rewardFromServer).perTaskCredit;
242
243
  const rewardHead = perTask ? 'Reward' : 'Reward (est.)';
243
244
  const headers = ['#', 'Version', ROLE_AXIS_LABEL, rewardHead, 'Est.', 'What it is'];
244
- const body = rows.map((t) => [
245
+ const rowCells = (t) => [
245
246
  surface.taskRef(t.id, HALL_TASK),
246
247
  cell(t.version_id || '?'),
247
248
  cell(roleLabel(t) || '—'),
248
249
  `${t.credits_reward ?? 0}c`,
249
250
  estimateShort(t),
250
251
  cell(whatItIs(t)),
251
- ]);
252
- const out = [surface.table(headers, body)];
252
+ ];
253
+ // task 1002852: one table per goal, each under its goal's name; goal-less work
254
+ // last under "Other work". An empty goal list (a failed read) still groups by id.
255
+ const out = [];
256
+ for (const g of startGoals.groupByGoal(rows, goals)) {
257
+ if (out.length) out.push('');
258
+ out.push(surface.strong(g.title) + (g.joined ? ' · your goal' : ''), surface.table(headers, g.rows.map(rowCells)));
259
+ }
253
260
  const cpNote = costPlusOnlyNote(rewardFromServer);
254
261
  if (cpNote) out.push('', surface.em(cpNote));
255
262
  return out.join('\n');
@@ -374,6 +381,7 @@ const WIDGET_STYLE = `<style>
374
381
  .bs-thead{text-align:left;color:var(--color-text-tertiary);font-size:12px;}
375
382
  .bs-th{padding:0 6px 4px;font-weight:400;}
376
383
  .bs-tr{cursor:pointer;}
384
+ .bs-group td{padding:10px 0 4px;font-size:12px;font-weight:500;color:var(--color-text-secondary);}
377
385
  .bs-td{padding:7px 6px;border-top:0.5px solid var(--color-border-tertiary);}
378
386
  .bs-id{color:var(--color-text-secondary);font-family:var(--font-mono);font-size:12px;}
379
387
  .bs-nowrap{white-space:nowrap;}
@@ -384,7 +392,7 @@ const WIDGET_STYLE = `<style>
384
392
  .bs-foot{font-size:13px;color:var(--color-text-tertiary);margin-top:12px;}
385
393
  </style>`;
386
394
 
387
- function renderWidget({ builder, claims, claimable, total, streak, recommendations, header, routingByPreference, preferred, filters, readyToClose, needsRebaseTasks }) {
395
+ function renderWidget({ builder, claims, claimable, total, streak, recommendations, header, routingByPreference, preferred, filters, readyToClose, needsRebaseTasks, goals, yourGoals }) {
388
396
  const b = builder || {};
389
397
  // ADR 0120 part 5: the owed-rebase gate warning, rendered at the top of the
390
398
  // card (before status/holdings) since it blocks new claims. '' when none owed.
@@ -443,7 +451,7 @@ function renderWidget({ builder, claims, claimable, total, streak, recommendatio
443
451
 
444
452
  // rows — click to claim. Columns: # / Version / Type / Reward /
445
453
  // Est. / What it is (the work lane is its own column, not baked into the title).
446
- const rows = claimable.map((t) => {
454
+ const taskRow = (t) => {
447
455
  const claimMsg = jsq(`Claim task ${t.id}`);
448
456
  const titleAttr = hesc(`Claim #${t.id}: ${t.title || ''}`);
449
457
  const d = t.discipline;
@@ -458,7 +466,16 @@ function renderWidget({ builder, claims, claimable, total, streak, recommendatio
458
466
  + TD(hesc(estimateShort(t)), 'bs-sub')
459
467
  + TD(hesc(t.title || ''), 'bs-ellip')
460
468
  + `</tr>`;
461
- }).join('');
469
+ };
470
+ // task 1002852: the same rows, grouped under each task's goal ("Other work" last).
471
+ const rows = startGoals.groupByGoal(claimable, goals).map((g) =>
472
+ `<tr class="bs-group"><td colspan="6">${hesc(g.title)}${g.joined ? '<span class="bs-sub"> · your goal</span>' : ''}</td></tr>`
473
+ + g.rows.map(taskRow).join('')).join('');
474
+ // task 1002852: the goals this builder joined, ABOVE the table (never instead of it).
475
+ const goalsBlock = yourGoals.goals.length
476
+ ? `<div class="bs-menuh">Your goals</div>${yourGoals.goals.map((g) =>
477
+ `<div class="bs-line"><i class="ti ti-target" aria-hidden="true"></i>${hesc(clip(g.title, 60))}<span class="bs-dot"> · </span><span class="bs-sub">${hesc(startGoals.claimableWord(g.claimable))}</span></div>`).join('')}${yourGoals.more ? `<div class="bs-line bs-sub">and ${yourGoals.more} more</div>` : ''}`
478
+ : '';
462
479
 
463
480
  const menu = claimable.length
464
481
  ? `<table class="bs-table">
@@ -495,6 +512,7 @@ function renderWidget({ builder, claims, claimable, total, streak, recommendatio
495
512
  ${statusRow}
496
513
  ${holdings}
497
514
  ${closeBanner}
515
+ ${goalsBlock}
498
516
  <div class="bs-menuh">Claim one to start — ${total || claimable.length} ready for you right now${matched}${fNote}</div>
499
517
  ${menu}
500
518
  <div class="bs-foot">Click a task to claim it${moreLink}${crossLane}</div>
@@ -679,9 +697,9 @@ async function main() {
679
697
  // endpoint fails, that widget degrades to "N/A" rather than killing
680
698
  // /builder-start.
681
699
  const api = await cliClient();
682
- let me, r, closeRes, progressRes, costRes, recentRes, inboxRes, blockersRes;
700
+ let me, r, closeRes, progressRes, costRes, recentRes, inboxRes, blockersRes, goalsRes;
683
701
  try {
684
- [me, r, closeRes, progressRes, costRes, recentRes, inboxRes, blockersRes] = await Promise.all([
702
+ [me, r, closeRes, progressRes, costRes, recentRes, inboxRes, blockersRes, goalsRes] = await Promise.all([
685
703
  api.me.getMe(),
686
704
  api.tasks.getTasksClaimable({ query: claimableQuery }),
687
705
  api.versions.getVersionsCloseSuggestions(),
@@ -690,6 +708,8 @@ async function main() {
690
708
  api.public.getPublicRecentShipped(),
691
709
  api.inbox.getInbox({ query: { status: 'open' } }),
692
710
  api.blockers.getBlockers(),
711
+ // task 1002852: best-effort — a failed goals read leaves the table flat-grouped by id.
712
+ api.goals.getGoals({ query: { status: 'open' } }).catch(() => ({ ok: false })),
693
713
  ]);
694
714
  } catch (err) {
695
715
  // A fresh-clone instance that was never brought up refuses the connection.
@@ -741,7 +761,10 @@ async function main() {
741
761
  const filtered = routingByPreference
742
762
  ? rawClaimable.filter((t) => preferred.includes(t.discipline))
743
763
  : rawClaimable;
744
- const claimable = filtered.slice(0, limit);
764
+ // task 1002852: goal-first. The open goals (with the server's viewer_is_member)
765
+ // group the table and put the builder's joined goals' rows ahead of the cap.
766
+ const goals = goalsRes && goalsRes.ok && Array.isArray(goalsRes.data?.goals) ? goalsRes.data.goals : [];
767
+ const claimable = startGoals.joinedFirst(filtered, goals).slice(0, limit);
745
768
 
746
769
  // "You might enjoy" — up to 3 claimable tasks outside the builder's lanes.
747
770
  // Computed from the FULL set so it can reach across disciplines. Suppressed
@@ -758,6 +781,7 @@ async function main() {
758
781
  ? closeRes.data.suggestions
759
782
  : [];
760
783
  const readyToClose = closeSuggestions.filter((s) => s.ready);
784
+ const yourGoals = startGoals.yourGoals(goals, filtered);
761
785
 
762
786
  // Header widget data (each independently optional).
763
787
  const progress = progressRes.ok && Array.isArray(progressRes.data?.progress) ? progressRes.data.progress : null;
@@ -781,6 +805,7 @@ async function main() {
781
805
  me: me.data,
782
806
  claimable,
783
807
  recommendations,
808
+ your_goals: yourGoals.goals,
784
809
  discipline_routing: {
785
810
  preferred_disciplines: preferred,
786
811
  applied: routingByPreference,
@@ -836,6 +861,8 @@ async function main() {
836
861
  filters: { version, discipline, fit },
837
862
  readyToClose,
838
863
  needsRebaseTasks,
864
+ goals,
865
+ yourGoals,
839
866
  }) + '\n');
840
867
  return;
841
868
  }
@@ -951,11 +978,18 @@ async function main() {
951
978
  fit && `fit=${fit}`,
952
979
  ].filter(Boolean);
953
980
  const filterNote = filterBits.length ? ` (${filterBits.join(', ')})` : '';
981
+ // task 1002852: the goals this builder joined, above the table (never instead of it).
982
+ if (yourGoals.goals.length) {
983
+ console.log(surface.strong('Your goals'));
984
+ for (const g of yourGoals.goals) console.log(` ${clip(g.title, 60)} · ${startGoals.claimableWord(g.claimable)}`);
985
+ if (yourGoals.more) console.log(` and ${yourGoals.more} more`);
986
+ console.log('');
987
+ }
954
988
  console.log(`${surface.strong('Claim one to start')} — ${filtered.length} ready for you right now${matched}${filterNote}:`);
955
989
  console.log('');
956
990
  if (claimable.length) {
957
991
  const cardRows = claimable.slice(0, CARD_ROWS);
958
- console.log(claimableTable(cardRows, me.ok ? me.data?.reward : null));
992
+ console.log(claimableTable(cardRows, me.ok ? me.data?.reward : null, goals));
959
993
  const moreN = filtered.length - cardRows.length;
960
994
  if (moreN > 0) {
961
995
  console.log('');
@@ -1012,6 +1046,8 @@ async function main() {
1012
1046
  filters: { version, discipline, fit },
1013
1047
  readyToClose,
1014
1048
  needsRebaseTasks,
1049
+ goals,
1050
+ yourGoals,
1015
1051
  })
1016
1052
  : null;
1017
1053
  const markdown = captureLog(emitMarkdownCard).join('\n');
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.86'; // 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
@@ -0,0 +1,90 @@
1
+ // tests/start_goal_first.mjs — /builder-start leads with the builder's goals
2
+ // (task 1002852).
3
+ //
4
+ // The owner's rules (2026-10-08): the joined goals sit ABOVE the claimable table
5
+ // and never replace it, and a task with no goal is listed under "Other work".
6
+ // These drive the pure helpers in scripts/gds/start-goals.js and the real table
7
+ // renderer start.js uses, so the property that matters — every claimable row is
8
+ // still listed, exactly once — is checked on what the builder actually sees.
9
+
10
+ import { strict as assert } from 'node:assert';
11
+ import { createRequire } from 'node:module';
12
+ import { makeRunner } from './helpers.mjs';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const { test, summary } = makeRunner();
16
+ const sg = require('../scripts/gds/start-goals.js');
17
+
18
+ const GOALS = [
19
+ { id: 10, title: 'Alpha', status: 'open', viewer_is_member: false },
20
+ { id: 20, title: 'Mine', status: 'open', viewer_is_member: true },
21
+ { id: 30, title: 'Done one', status: 'achieved', viewer_is_member: true },
22
+ { id: 40, title: 'Also mine', status: 'open', viewer_is_member: true },
23
+ ];
24
+ const ROWS = [
25
+ { id: 1, goal_id: 10, title: 'a1' },
26
+ { id: 2, goal_id: null, title: 'loose' },
27
+ { id: 3, goal_id: 20, title: 'm1' },
28
+ { id: 4, goal_id: 99, title: 'unknown goal' },
29
+ { id: 5, goal_id: 20, title: 'm2' },
30
+ ];
31
+
32
+ await test('joined goals are the open ones the server says the viewer is a member of', () => {
33
+ assert.deepEqual(sg.joinedGoals(GOALS).map((g) => g.id), [20, 40]);
34
+ });
35
+
36
+ await test('groups: joined goals first, then other goals, then "Other work" last; every row exactly once', () => {
37
+ const groups = sg.groupByGoal(ROWS, GOALS);
38
+ assert.deepEqual(groups.map((g) => g.title), ['Mine', 'Alpha', 'Goal 99', sg.OTHER_WORK]);
39
+ assert.deepEqual(groups[0].rows.map((r) => r.id), [3, 5], 'server order kept inside a group');
40
+ assert.equal(groups[0].joined, true);
41
+ assert.equal(groups[3].goal_id, null);
42
+ const ids = groups.flatMap((g) => g.rows.map((r) => r.id)).sort();
43
+ assert.deepEqual(ids, [1, 2, 3, 4, 5]);
44
+ });
45
+
46
+ await test('with no goal list (a failed read) nothing is hidden: goals by id, goal-less under Other work', () => {
47
+ const groups = sg.groupByGoal(ROWS, []);
48
+ assert.equal(groups.flatMap((g) => g.rows).length, ROWS.length);
49
+ assert.equal(groups.at(-1).title, sg.OTHER_WORK);
50
+ assert.ok(groups.every((g) => !g.joined));
51
+ });
52
+
53
+ await test('joinedFirst moves the joined goals\' rows ahead of the cap and drops nothing', () => {
54
+ const out = sg.joinedFirst(ROWS, GOALS);
55
+ assert.deepEqual(out.map((r) => r.id), [3, 5, 1, 2, 4]);
56
+ assert.deepEqual(sg.joinedFirst(ROWS, []).map((r) => r.id), [1, 2, 3, 4, 5], 'no joined goal: order unchanged');
57
+ });
58
+
59
+ await test('"Your goals" counts claimable work per joined goal from the full set', () => {
60
+ const y = sg.yourGoals(GOALS, ROWS);
61
+ assert.deepEqual(y.goals, [
62
+ { id: '20', title: 'Mine', claimable: 2 },
63
+ { id: '40', title: 'Also mine', claimable: 0 },
64
+ ]);
65
+ assert.equal(y.more, 0);
66
+ assert.equal(sg.claimableWord(0), 'nothing to claim right now');
67
+ assert.equal(sg.claimableWord(2), '2 to claim');
68
+ });
69
+
70
+ await test('a builder in no goal gets no goals list, and the table still lists everything', () => {
71
+ const none = GOALS.map((g) => ({ ...g, viewer_is_member: false }));
72
+ assert.deepEqual(sg.yourGoals(none, ROWS).goals, []);
73
+ assert.equal(sg.groupByGoal(ROWS, none).flatMap((g) => g.rows).length, ROWS.length);
74
+ });
75
+
76
+ await test('start.js renders "Your goals" ABOVE the table and groups the table by goal (the wiring)', () => {
77
+ const fs = require('node:fs');
78
+ const src = fs.readFileSync(require.resolve('../scripts/gds/start.js'), 'utf8');
79
+ const goalsAt = src.indexOf("surface.strong('Your goals')");
80
+ const tableAt = src.indexOf("surface.strong('Claim one to start')");
81
+ assert.ok(goalsAt > 0 && goalsAt < tableAt, 'markdown: goals before the table');
82
+ const wGoals = src.indexOf('${goalsBlock}');
83
+ const wMenu = src.indexOf('<div class="bs-menuh">Claim one to start');
84
+ assert.ok(wGoals > 0 && wGoals < wMenu, 'widget: goals before the table');
85
+ assert.match(src, /claimableTable\(cardRows, [^)]*, goals\)/, 'the markdown table is grouped');
86
+ assert.equal((src.match(/\n\s+goals,\n\s+yourGoals,\n/g) || []).length, 2, 'both widget calls carry the goals');
87
+ assert.match(src, /startGoals\.joinedFirst\(filtered, goals\)\.slice\(0, limit\)/);
88
+ });
89
+
90
+ summary();