@bongos/core 1.20.42 → 1.20.43

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.
Files changed (55) hide show
  1. package/.bongos-core.json +82 -62
  2. package/.claude/skills/backlog-review/SKILL.md +1 -0
  3. package/.claude/skills/blocker-review/SKILL.md +1 -1
  4. package/.claude/skills/bug-triage/SKILL.md +1 -0
  5. package/.claude/skills/builder-backup/SKILL.md +1 -0
  6. package/.claude/skills/builder-claim/SKILL.md +1 -1
  7. package/.claude/skills/builder-cost/SKILL.md +1 -0
  8. package/.claude/skills/builder-exit/SKILL.md +1 -0
  9. package/.claude/skills/builder-key/SKILL.md +1 -1
  10. package/.claude/skills/builder-reauth/SKILL.md +1 -1
  11. package/.claude/skills/builder-redteam/SKILL.md +1 -1
  12. package/.claude/skills/builder-sequence/SKILL.md +1 -1
  13. package/.claude/skills/builder-setup/SKILL.md +1 -1
  14. package/.claude/skills/collab-review/SKILL.md +1 -1
  15. package/.claude/skills/design/SKILL.md +1 -1
  16. package/.claude/skills/design-sync/SKILL.md +1 -0
  17. package/.claude/skills/feedback/SKILL.md +1 -1
  18. package/.claude/skills/figma-design-sync/SKILL.md +1 -0
  19. package/.claude/skills/goal-close/SKILL.md +184 -0
  20. package/.claude/skills/goal-create/SKILL.md +1 -1
  21. package/.claude/skills/goal-review/SKILL.md +5 -102
  22. package/.claude/skills/goal-uat/SKILL.md +5 -81
  23. package/.claude/skills/grade-audit/SKILL.md +6 -85
  24. package/.claude/skills/grade-recover/SKILL.md +1 -1
  25. package/.claude/skills/grade-sweep/SKILL.md +174 -0
  26. package/.claude/skills/grader-health/SKILL.md +5 -72
  27. package/.claude/skills/idea-triage/SKILL.md +1 -0
  28. package/.claude/skills/merge-mode/SKILL.md +1 -1
  29. package/.claude/skills/new-project/SKILL.md +1 -0
  30. package/.claude/skills/owner-review/SKILL.md +40 -0
  31. package/.claude/skills/planning-session/SKILL.md +1 -0
  32. package/.claude/skills/priority-session/SKILL.md +1 -0
  33. package/.claude/skills/read-session-export/SKILL.md +1 -1
  34. package/.claude/skills/recall/SKILL.md +1 -1
  35. package/.claude/skills/scan-before-install/SKILL.md +1 -1
  36. package/.claude/skills/session-handoff/SKILL.md +1 -1
  37. package/.claude/skills/worktree-clean/SKILL.md +1 -1
  38. package/docs/file-map.md +12 -10
  39. package/docs/module-api-changelog.md +2 -0
  40. package/docs/onboarding/slash-commands.md +17 -11
  41. package/docs/packs/artist.md +1 -1
  42. package/docs/packs/engineer.md +4 -5
  43. package/modules/copy-desk/module.json +1 -1
  44. package/{.claude → modules/copy-desk}/skills/tweak/SKILL.md +1 -0
  45. package/modules/lifecycle/dependency-advisory.js +2 -2
  46. package/package-lock.json +2 -2
  47. package/package.json +1 -1
  48. package/release-notes.json +6 -0
  49. package/scripts/gds/fitness-ratchets.js +2 -0
  50. package/scripts/gds/skill-lint.js +32 -9
  51. package/src/module-api.js +1 -1
  52. package/tests/skill_grade_audit.mjs +34 -17
  53. package/tests/skill_grader_health.mjs +28 -10
  54. package/tests/skill_lint.mjs +37 -0
  55. package/tests/skill_menu_ruling.mjs +99 -0
@@ -42,29 +42,31 @@ Every one of these talks to the build system. They're grouped by when you'd reac
42
42
  | `/builder-setup` | One-time enrolment — signs you in and registers you |
43
43
  | `/builder-reauth` | Refresh an expired session |
44
44
  | `/builder-key` | Check / set your own API key status |
45
- | `/builder-cost` | Log a cost entry against your claim |
46
- | `/builder-backup` | Check or trigger a database backup before risky work |
47
- | `/builder-exit` | Offboard or reactivate a builder *(Archon only)* |
45
+ | `/builder-cost` † | Log or read project spend |
46
+ | `/builder-backup` † | Check or trigger a database backup before risky work |
47
+ | `/builder-exit` † | Offboard or reactivate a builder *(Archon only)* |
48
48
 
49
49
  ### Triage and planning *(Metic and above)*
50
50
 
51
51
  | Command | What it does |
52
52
  |---|---|
53
- | `/idea-triage` | Walk the idea inbox — promote, discard, or merge each |
53
+ | `/owner-review` | One walk through everything waiting on a decision: backlog → bugs → ideas → priorities. Say *"triage ideas"* or *"review the backlog"* and it opens at that step |
54
54
  | `/blocker-review` · `/blocker-solve` | Walk open blockers, or drive one to done |
55
55
  | `/goal-create` | Plan and create one goal — scope wall, criteria, seed tasks |
56
- | `/goal-review` | Confirm criteria that are flagged met-pending-review |
57
- | `/planning-session` | Scope an upcoming version and seed its task list |
58
- | `/priority-session` | Reweight the inbox from a few plain-speech answers |
56
+ | `/goal-close` † | Close a goal's criteria: test them on the live site and sign off, then decide the ones nothing was delivered for |
57
+ | `/planning-session` † | Scope an upcoming version and seed its task list |
58
+ | `/grade-sweep` † | Check the grader is healthy, then audit what shipped past it |
59
59
  | `/merge-mode` | Manual fallback when a confirmed task didn't land |
60
60
 
61
+ The four steps of `/owner-review` still answer to their old names — `/backlog-review`, `/bug-triage`, `/idea-triage`, `/priority-session` — and so do `/goal-uat` · `/goal-review` (now the two parts of `/goal-close`) and `/grader-health` · `/grade-audit` (now the two parts of `/grade-sweep`). All of those are typed-only (†).
62
+
61
63
  ### Discipline modes — not slash commands any more
62
64
 
63
65
  The three core crafts no longer have a slash command. `/dev`, `/paint` and `/ideate` were **deleted** when the root instructions split into a role-neutral kernel (`CLAUDE.md`) plus one **role pack** per craft: `docs/packs/engineer.md`, `docs/packs/artist.md`, `docs/packs/ideator.md`. A claimed task's discipline routes you to your pack — the Conductor injects its path into your session on the next prompt, and `claim.js` prints a one-line reminder — and you read it. A module-contributed discipline can still ship a skill, which is how `ui` → `/design` below works.
64
66
 
65
67
  ### Design and art pipeline
66
68
 
67
- `/design` is the ui-discipline playbook (world-first, show-first — shipped by the `ui-design` module, ADR 0197); `/design-sync` and `/figma-design-sync` round-trip the UI design system. The `/otb-*` family — `otb-tile-generate`, `otb-design-review`, `otb-character-review`, `otb-feedback-capture`, `otb-figma-sync` — is the reference instance's pixel-art pipeline; it's only meaningful on an instance that runs that pipeline, so it ships in the `pixel-art` module, which is off unless the project turns it on. The design-style skills (`/impeccable`, `/style`, the taste and look skills) are the same: the `design-styles` module, off unless turned on — `/design` tells you which.
69
+ `/design` is the ui-discipline playbook (world-first, show-first — shipped by the `ui-design` module, ADR 0197); `/design-sync` † and `/figma-design-sync` † round-trip the UI design system. `/tweak` † applies the next page an artist rewrote in the studio; it ships in the `copy-desk` module, where the studio lives. The `/otb-*` family — `otb-tile-generate`, `otb-design-review`, `otb-character-review`, `otb-feedback-capture`, `otb-figma-sync` — is the reference instance's pixel-art pipeline; it's only meaningful on an instance that runs that pipeline, so it ships in the `pixel-art` module, which is off unless the project turns it on. The design-style skills (`/impeccable`, `/style`, the taste and look skills) are the same: the `design-styles` module, off unless turned on — `/design` tells you which.
68
70
 
69
71
  ### Session and meta
70
72
 
@@ -73,9 +75,13 @@ The three core crafts no longer have a slash command. `/dev`, `/paint` and `/ide
73
75
  | `/session-handoff` | Emit a paste-ready prompt to start a fresh session with |
74
76
  | `/read-session-export` | Read a past session's `/export` zip |
75
77
  | `/feedback` | Pull in the latest recorded walkthrough bundle |
76
- | `/new-project` | Zero-to-live runbook for a brand-new instance |
78
+ | `/new-project` † | Zero-to-live runbook for a brand-new instance |
77
79
  | `/builder-redteam` | File a security / vulnerability report |
78
80
 
81
+ ### † Typed-only commands
82
+
83
+ A command marked † is **hidden from Claude's own menu but still works when you type it**. Every listed command's description sits in every session's context whether it is used or not, so the rarely-used admin ones carry `disable-model-invocation: true` in their `SKILL.md`: Claude Code leaves them out of what the model sees, and only a person typing `/name` runs them (task 1004471). The catch: plain English will not reach them, and Claude will not start one on its own — type the slash form.
84
+
79
85
  ## Claude Code's own commands
80
86
 
81
87
  These work in **any** repository — they have nothing to do with this project. `/help` prints the live, authoritative list for your version; the ones below are simply the ones you'll actually meet.
@@ -111,11 +117,11 @@ These are the ones that actually cause confusion.
111
117
 
112
118
  **`/export` vs `/read-session-export`.** Claude's `/export` *writes* a transcript zip; our `/read-session-export` *reads* one back.
113
119
 
114
- **`/review` reviews a GitHub PR.** The project's review commands are `/goal-review`, `/blocker-review`, and `/idea-triage` — different jobs entirely.
120
+ **`/review` reviews a GitHub PR.** The project's review commands are `/owner-review`, `/blocker-review`, and `/goal-close` — different jobs entirely.
115
121
 
116
122
  ## Two more things worth knowing
117
123
 
118
- **Every command has a plain-English twin.** Each skill lists trigger phrases, so *"what can I work on"* reaches `/builder-start` and *"ship it"* reaches `/builder-ship`. You never have to memorise the slash form.
124
+ **Every listed command has a plain-English twin.** Each skill lists trigger phrases, so *"what can I work on"* reaches `/builder-start` and *"ship it"* reaches `/builder-ship`. You never have to memorise the slash form — except for the typed-only † commands above.
119
125
 
120
126
  **Same commands, outside Claude.** The work-loop commands are also a terminal CLI: `bongos start`, `bongos claim N`, `bongos ship`. Same actions, same API — usable from any terminal, a Chromebook, or CI. Run `bongos help` for the verb list.
121
127
 
@@ -27,7 +27,7 @@ The studio's own words (the greeting and its sub-line) are the studio page itsel
27
27
 
28
28
  Only the holder writes. A page someone else holds is read-only and names them. A draft left unsubmitted keeps its words and can be taken up again.
29
29
 
30
- **Between submit and approval, a session applies it.** Submitted pages wait for a builder session running `/tweak` ([the skill](../../.claude/skills/tweak/SKILL.md), Metic+). It transcribes every line, renders the page before and after, and parks it. **It never lands the page.**
30
+ **Between submit and approval, a session applies it.** Submitted pages wait for a builder session running `/tweak` ([the skill](../../modules/copy-desk/skills/tweak/SKILL.md), Metic+). It transcribes every line, renders the page before and after, and parks it. **It never lands the page.**
31
31
 
32
32
  **The Approval queue.** Pictures of the applied page, desktop or phone, light or dark, flipped **before / after**. Beside them, the changed lines ("was:" / "becomes:"); a line the applier could not apply says why. Then:
33
33
 
@@ -74,7 +74,7 @@ If this is an autonomous, bypass-permissions, or scheduled run and no one will a
74
74
 
75
75
  1. **Resolve your claim** — `/builder-ship` with handoff notes and a value summary, or `/builder-release` on abandonment. Bongos records the session-log row and awards credits.
76
76
  2. **Write a session log** as its own file at `docs/session-logs/YYYY-MM-DD-<slug>.md`, using [`docs/handoff-template.md`](../handoff-template.md). Do not hand-edit the kernel's §13 index or `docs/session-log-index.md` — both regenerate from your file at ship. For a single-task session the Bongos `session_logs` row is enough and a separate file isn't needed.
77
- 3. **Review the `idea_inbox`** via `GET /api/bongos/inbox`, or run `/idea-triage`.
77
+ 3. **Review the `idea_inbox`** via `GET /api/bongos/inbox`, or run `/owner-review ideas`.
78
78
  4. **Update the detail files** if anything changed: a new gotcha → a recipe or a captured learning; a new blocker → `POST /api/bongos/blockers`; a decision → a new ADR.
79
79
  5. **Update [`docs/architecture.md`](../architecture.md)** if the live system changed, and [`docs/file-map.md`](../file-map.md) if files or folders moved.
80
80
 
@@ -82,13 +82,12 @@ If nothing changed in a section, leave it alone — don't churn.
82
82
 
83
83
  ### Daily cadence (once per day, not per session)
84
84
 
85
- Three manual slash-commands keep the methodology surfaces from accumulating drift. Any session can pick them up; it only matters that they happen.
85
+ Two manual slash-commands keep the methodology surfaces from accumulating drift. Any session can pick them up; it only matters that they happen.
86
86
 
87
- - **`/idea-triage`** — walk the open `idea_inbox` rows: promote, discard, merge, or defer. Deferring is fine; the cadence is the discipline.
87
+ - **`/owner-review`** — one walk through backlog → bugs → ideas → priorities (task 1004471); each step is still typeable alone (`/backlog-review`, `/bug-triage`, `/idea-triage`, `/priority-session`). Ideas: promote, discard, merge, or defer — deferring is fine; the cadence is the discipline. Backlog: rows waiting on a **person** get walked (promote / kill / water); rows waiting on a **trigger** are counted, never walked (a satisfied dep auto-promotes), and rows stranded behind an abandoned dep are surfaced. `/demote` is invalid on a backlog row (409 `cannot_demote` — it requires `ready`).
88
88
  - **`/blocker-review`** — walk the open blockers: resolved (which auto-promotes linked tasks via a DB trigger), still blocked with a note, or escalate.
89
- - **`/backlog-review`** — walk `status='backlog'`, the pre-workable state a human must say go on. Rows waiting on a **person** get walked (promote / kill / water); rows waiting on a **trigger** are counted, never walked (a satisfied dep auto-promotes). It also surfaces rows stranded behind an abandoned dep, which the trigger can never fire for. `/demote` is invalid on a backlog row (409 `cannot_demote` — it requires `ready`).
90
89
 
91
- **`/tweak`** (Metic+) works a fourth queue: pages an artist submitted from the studio wait there until a session applies them. It never lands one; the artist approves first (ADR 0341).
90
+ **`/tweak`** (Metic+, typed-only, in the `copy-desk` module) works a fourth queue: pages an artist submitted from the studio wait there until a session applies them. It never lands one; the artist approves first (ADR 0341).
92
91
 
93
92
  If a day passes without them, the queues quietly grow. Running them is the structural cure for the markdown-graveyard pattern.
94
93
 
@@ -6,7 +6,7 @@
6
6
  "coreVersion": "^1.8.0",
7
7
  "default": true,
8
8
  "maintenance": { "status": "core-maintained" },
9
- "contributes": { "routes": ["copy-desk"], "migrations": true },
9
+ "contributes": { "routes": ["copy-desk"], "migrations": true, "skills": ["tweak"] },
10
10
  "provides": [],
11
11
  "consumes": ["lifecycle"]
12
12
  }
@@ -2,6 +2,7 @@
2
2
  name: tweak
3
3
  description: >-
4
4
  Apply the next submitted page tweak: claim the round, apply every line, render the page before and after, attach the renders and ship it to wait for the artist. Metic+. Triggers: "/tweak", "apply the next page tweak", "work the tweak queue".
5
+ disable-model-invocation: true
5
6
  plain: >-
6
7
  Applies the next set of change requests someone made to a page, and shows before and after pictures for the artist to approve.
7
8
  reach-for: >-
@@ -17,9 +17,9 @@
17
17
  // 1. POST /tasks attaches `dependency_advisory` to its 201 body (this
18
18
  // file's `buildDependencyAdvisory`), read by the /goal-create skill's
19
19
  // "sibling advisory" section (.claude/skills/goal-create/SKILL.md).
20
- // 2. The periodic sweep: `/goal-review`'s daily walk runs
20
+ // 2. The periodic sweep: `/goal-close`'s residue walk (was `/goal-review`) runs
21
21
  // `node scripts/gds/audit-deps.js --min high` as one more housekeeping
22
- // step (idea 1000276's half — see .claude/skills/goal-review/SKILL.md).
22
+ // step (idea 1000276's half — see .claude/skills/goal-close/SKILL.md, Part 2).
23
23
  //
24
24
  // HARD CONSTRAINT, same as goal-advisory.js: advisory only (a finding addressed
25
25
  // to a human never acts by itself — ADR 0158 §2). This
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.42",
3
+ "version": "1.20.43",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.42",
9
+ "version": "1.20.43",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.42",
3
+ "version": "1.20.43",
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",
@@ -8361,5 +8361,11 @@
8361
8361
  "id": "1003792",
8362
8362
  "text": "The module store can now run a security check on each module version: it fails a version that ships a secret file, pulls code from outside the official package registry, or depends on a package with a known serious vulnerabili"
8363
8363
  }
8364
+ ],
8365
+ "1.20.43": [
8366
+ {
8367
+ "id": "1004471",
8368
+ "text": "Claude's list of project commands is now short enough to read in full every session: the rarely used admin commands still work when you type them but no longer take up room, and one new /owner-review walks the backlog, bugs, i"
8369
+ }
8364
8370
  ]
8365
8371
  }
@@ -423,6 +423,8 @@ function skillListingMetrics() {
423
423
  const lint = require('./skill-lint.js');
424
424
  // Resident skills only (task 1004470): a skill owned by a module this checkout leaves
425
425
  // off (pixel-art, design-styles — both default: false) is in no session's listing here.
426
+ // listingChars also leaves out HIDDEN skills (disable-model-invocation: true, task 1004471):
427
+ // Claude Code keeps their descriptions out of the model's context entirely.
426
428
  const files = lint.listSkillFiles();
427
429
  return { skill_listing_chars: lint.lintSkills(files, { resident: lint.residentSkillFiles(undefined, { files }) }).listingChars };
428
430
  } catch (e) {
@@ -14,6 +14,12 @@
14
14
  // context window, and a description is resident in EVERY session, so long ones
15
15
  // crowd the others out and get truncated (skill routing degrades).
16
16
  //
17
+ // A HIDDEN skill — frontmatter `disable-model-invocation: true` — is not in that
18
+ // listing at all: Claude Code keeps its description out of the model's context and
19
+ // loads it only when a person types /name (the model cannot invoke it). So the
20
+ // budget counts LISTED skills only, and hidden ones are reported on their own line
21
+ // (task 1004471, owner ruling on blocker 1000139).
22
+ //
17
23
  // NOT A YAML PARSER, on purpose. These are targeted regex checks for the hazards that
18
24
  // actually bit us (a plain scalar carrying ": " or " #", an unclosed quote or flow
19
25
  // collection, a tab, a continuation line), so the repo gains a CI gate with no new
@@ -95,11 +101,19 @@ function lintFrontmatter(lines) {
95
101
  return { errors, warnings, fields };
96
102
  }
97
103
 
104
+ // Whether a skill is HIDDEN from the model's listing: `disable-model-invocation: true`
105
+ // (Claude Code: "description not in context"; typeable as /name, never model-invoked).
106
+ // Anything but a literal true — absent, false, a typo — leaves it listed, so a mistake
107
+ // can only ever over-count the budget, never hide a skill nobody meant to hide.
108
+ function isHidden(fields) {
109
+ return String((fields && fields['disable-model-invocation']) || '').trim().toLowerCase() === 'true';
110
+ }
111
+
98
112
  // Lint one SKILL.md file's text. `file` is used for the name check and messages.
99
113
  function lintSkillFile(text, { file = 'SKILL.md' } = {}) {
100
114
  const dirName = path.basename(path.dirname(file));
101
115
  const fm = splitFrontmatter(text);
102
- const res = { file, errors: [], warnings: [], name: null, description: '', listingChars: 0 };
116
+ const res = { file, errors: [], warnings: [], name: null, description: '', listingChars: 0, hidden: false };
103
117
  if (!fm) { res.errors.push('no YAML frontmatter (the file must start with --- and carry name + description)'); return res; }
104
118
  const { errors, warnings, fields } = lintFrontmatter(fm.lines);
105
119
  res.errors.push(...errors); res.warnings.push(...warnings);
@@ -107,8 +121,10 @@ function lintSkillFile(text, { file = 'SKILL.md' } = {}) {
107
121
  if (!name) res.errors.push('missing "name"');
108
122
  else if (name !== dirName && dirName !== 'SKILL.md') res.warnings.push(`name "${name}" differs from its folder "${dirName}" — the folder name wins in listings`);
109
123
  if (!desc) res.errors.push('missing or empty "description" — the skill would match on its body text');
124
+ res.hidden = isHidden(fields);
110
125
  if (desc.length > DESC_HARD_CHARS) res.errors.push(`description is ${desc.length} chars — over Claude Code's ${DESC_HARD_CHARS}-char cap`);
111
- else if (desc.length > DESC_WARN_CHARS) res.warnings.push(`description is ${desc.length} chars — over the ~${DESC_WARN_CHARS} we aim for; every char is resident in every session`);
126
+ else if (desc.length > DESC_WARN_CHARS && !res.hidden) res.warnings.push(`description is ${desc.length} chars — over the ~${DESC_WARN_CHARS} we aim for; every char is resident in every session`);
127
+ // listingChars is what the entry WOULD cost if listed; lintSkills leaves hidden ones out of the total.
112
128
  res.name = name || dirName; res.description = desc; res.listingChars = res.name.length + desc.length;
113
129
  return res;
114
130
  }
@@ -164,13 +180,18 @@ function residentSkillFiles(root = REPO_ROOT, { instanceDir = root, files = list
164
180
  }
165
181
 
166
182
  // Lint a set of files; returns per-file results plus the listing total vs budget. The
167
- // listing counts `resident` (a list of paths) when given, else every linted file.
183
+ // listing counts `resident` (a list of paths) when given, else every linted file — and of
184
+ // those, only the LISTED ones: a hidden skill (isHidden) is resident but costs the model
185
+ // nothing, so it is tallied separately as hiddenSkills / hiddenChars (task 1004471).
168
186
  function lintSkills(files, { resident } = {}) {
169
187
  const results = files.map((f) => lintSkillFile(fs.readFileSync(f, 'utf8'), { file: f }));
170
188
  const counted = resident ? new Set(resident.map((f) => path.resolve(f))) : null;
171
- const listed = counted ? results.filter((r) => counted.has(path.resolve(r.file))) : results;
189
+ const here = counted ? results.filter((r) => counted.has(path.resolve(r.file))) : results;
190
+ const listed = here.filter((r) => !r.hidden);
191
+ const hidden = here.filter((r) => r.hidden);
172
192
  const listingChars = listed.reduce((a, r) => a + r.listingChars, 0);
173
- return { results, listingChars, listedSkills: listed.length, budgetChars: LISTING_BUDGET_CHARS, overBudget: listingChars > LISTING_BUDGET_CHARS };
193
+ const hiddenChars = hidden.reduce((a, r) => a + r.listingChars, 0);
194
+ return { results, listingChars, listedSkills: listed.length, residentSkills: here.length, hiddenSkills: hidden.length, hiddenChars, hiddenNames: hidden.map((r) => r.name), budgetChars: LISTING_BUDGET_CHARS, overBudget: listingChars > LISTING_BUDGET_CHARS };
174
195
  }
175
196
 
176
197
  // The PostToolUse hook's gate. It must match a module-owned SKILL.md too — that is the
@@ -190,8 +211,10 @@ function format(report, { relTo = REPO_ROOT } = {}) {
190
211
  }
191
212
  const errs = report.results.reduce((a, r) => a + r.errors.length, 0);
192
213
  const warns = report.results.reduce((a, r) => a + r.warnings.length, 0);
193
- const resident = report.listedSkills !== undefined && report.listedSkills !== report.results.length ? ` (the ${report.listedSkills} resident here; the rest belong to modules this instance leaves off)` : '';
194
- out.push(`skill-lint: ${report.results.length} skill(s), ${errs} error(s), ${warns} warning(s); listing ${report.listingChars} chars${resident} (~${Math.round(report.listingChars / 4)} tokens) vs ~${report.budgetChars}-char budget${report.overBudget ? ' — OVER: entries get truncated and routing degrades' : ''}`);
214
+ const residentN = report.residentSkills !== undefined ? report.residentSkills : report.listedSkills;
215
+ const resident = residentN !== undefined && residentN !== report.results.length ? ` ${residentN} resident here (the rest belong to modules this instance leaves off);` : '';
216
+ out.push(`skill-lint: ${report.results.length} skill(s), ${errs} error(s), ${warns} warning(s);${resident} listing ${report.listingChars} chars over ${report.listedSkills} listed skill(s) (~${Math.round(report.listingChars / 4)} tokens) vs ~${report.budgetChars}-char budget${report.overBudget ? ' — OVER: entries get truncated and routing degrades' : ''}`);
217
+ if (report.hiddenSkills) out.push(`skill-lint: ${report.hiddenSkills} hidden skill(s) (disable-model-invocation — typeable as /name, not in the model's listing, not counted): ${report.hiddenChars} chars — ${report.hiddenNames.join(', ')}`);
195
218
  return out.join('\n');
196
219
  }
197
220
 
@@ -210,7 +233,7 @@ function main() {
210
233
  if (!isSkillMd(fp) || !fs.existsSync(fp)) return;
211
234
  const report = lintSkills([fp]);
212
235
  const r = report.results[0];
213
- if (r.errors.length || r.warnings.length) console.log('[skill-lint] ' + format(report).split('\n').slice(0, -1).join('\n[skill-lint] '));
236
+ if (r.errors.length || r.warnings.length) console.log('[skill-lint] ' + format(report).split('\n').filter((l) => !l.startsWith('skill-lint:')).join('\n[skill-lint] '));
214
237
  } catch (_) { /* a hook never breaks the edit */ }
215
238
  process.exitCode = 0;
216
239
  });
@@ -223,5 +246,5 @@ function main() {
223
246
  process.exitCode = report.results.some((r) => r.errors.length) ? 1 : 0;
224
247
  }
225
248
 
226
- module.exports = { lintFrontmatter, lintSkillFile, lintSkills, listSkillFiles, residentSkillFiles, splitFrontmatter, isSkillMd, format, DESC_WARN_CHARS, DESC_HARD_CHARS, LISTING_BUDGET_CHARS };
249
+ module.exports = { lintFrontmatter, lintSkillFile, lintSkills, listSkillFiles, residentSkillFiles, splitFrontmatter, isSkillMd, isHidden, format, DESC_WARN_CHARS, DESC_HARD_CHARS, LISTING_BUDGET_CHARS };
227
250
  if (require.main === module) main();
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.42'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.43'; // 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');
@@ -1,9 +1,10 @@
1
- // tests/skill_grade_audit.mjs — the /grade-audit skill exists and covers the
2
- // accountability sweep from the grader-hardening evaluation §6 (task 1002657):
3
- // the override ledger, dropped blocker/major findings on passing grades (with
4
- // the advisory-Narc attribution filter), the false-pass spot-check, and the
5
- // outage roll-up handoff — describing and queueing ONLY (ADR 0158 §2: it
6
- // never confirms a task or flips status; ADR 0162: it adds no approval step to a ship).
1
+ // tests/skill_grade_audit.mjs — the grade AUDIT (once /grade-audit, now Part B of the
2
+ // merged /grade-sweep — task 1004471) covers the accountability sweep from the
3
+ // grader-hardening evaluation §6 (task 1002657): the override ledger, dropped
4
+ // blocker/major findings on passing grades (with the advisory-Narc attribution
5
+ // filter), the false-pass spot-check, and the outage roll-up handoff — describing and
6
+ // queueing ONLY (ADR 0158 §2: it never confirms a task or flips status; ADR 0162: it
7
+ // adds no approval step to a ship). /grade-audit stays typeable as a hidden alias.
7
8
  import assert from 'node:assert/strict';
8
9
  import { test } from 'node:test';
9
10
  import { readFileSync, existsSync } from 'node:fs';
@@ -11,16 +12,22 @@ import { fileURLToPath } from 'node:url';
11
12
  import path from 'node:path';
12
13
 
13
14
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
14
- const SKILL = path.join(ROOT, '.claude', 'skills', 'grade-audit', 'SKILL.md');
15
+ const SKILL = path.join(ROOT, '.claude', 'skills', 'grade-sweep', 'SKILL.md');
16
+ const ALIAS = path.join(ROOT, '.claude', 'skills', 'grade-audit', 'SKILL.md');
15
17
 
16
18
  // .claude/skills is INSTANCE-materialized; a neutral core checkout carries none
17
19
  // (task 1002470) — skip there, same guard shape as file_map.mjs.
18
20
  const skillsDirExists = existsSync(path.join(ROOT, '.claude', 'skills'));
19
21
 
20
- test('/grade-audit exists, covers the sweep legs, and stays describe-and-queue only', (t) => {
22
+ // Part B of the merged file: from its heading to the shared report section.
23
+ const partB = (md) => md.slice(md.indexOf('## Part B'), md.indexOf('## Report shape'));
24
+
25
+ test('the grade audit (grade-sweep Part B) covers the sweep legs, and stays describe-and-queue only', (t) => {
21
26
  if (!skillsDirExists) return t.skip('.claude/skills not materialized in this checkout');
22
- assert.ok(existsSync(SKILL), '.claude/skills/grade-audit/SKILL.md must exist (task 1002657)');
23
- const md = readFileSync(SKILL, 'utf8');
27
+ assert.ok(existsSync(SKILL), '.claude/skills/grade-sweep/SKILL.md must exist (task 1004471, was grade-audit — task 1002657)');
28
+ const whole = readFileSync(SKILL, 'utf8');
29
+ const md = partB(whole);
30
+ assert.ok(md.length > 1000, 'Part B is present');
24
31
 
25
32
  // Data surfaces.
26
33
  assert.match(md, /\/api\/bongos\/public\/recent-shipped/, 'sweeps the recent-shipped window');
@@ -35,16 +42,26 @@ test('/grade-audit exists, covers the sweep legs, and stays describe-and-queue o
35
42
  assert.match(md, /blocker|major/, 'targets blocker/major findings on passing grades');
36
43
  assert.match(md, /Narc/i, 'carries the advisory-Narc attribution filter');
37
44
  assert.match(md, /capture\.js/, 'queues findings via capture.js');
38
- assert.match(md, /\[grade-audit\]/, 'uses the deterministic title marker for idempotency');
45
+ assert.match(md, /\[grade-audit\]/, 'keeps the deterministic title marker for idempotency (old rows carry it)');
39
46
 
40
47
  // Leg 3 — false-pass spot-check (the 2/8 runtime-blindness classes).
41
48
  assert.match(md, /runtime/i, 'spot-checks for runtime-behavior blindness');
42
49
 
43
- // Leg 4 — outage roll-up handoff.
44
- assert.match(md, /\/grader-health/, 'hands outage windows to /grader-health');
50
+ // Leg 4 — outage roll-up hands a streak to the health half, now in the same file.
51
+ assert.match(md, /Part A/, 'hands outage streaks to Part A (was /grader-health)');
52
+
53
+ // The hard constraint — stated once for the whole merged skill.
54
+ assert.match(whole, /never confirm/i, 'states it never confirms');
55
+ assert.match(whole, /ADR 0162/, 'anchors the no-approval-step-on-a-ship constraint');
56
+ assert.ok(!/curl[^\n]*\/confirm/.test(whole), 'carries no ready-to-paste confirm curl');
57
+ });
45
58
 
46
- // The hard constraint.
47
- assert.match(md, /never confirm/i, 'states it never confirms');
48
- assert.match(md, /ADR 0162/, 'anchors the no-approval-step-on-a-ship constraint');
49
- assert.ok(!/curl[^\n]*\/confirm/.test(md), 'carries no ready-to-paste confirm curl');
59
+ test('/grade-audit still works when typed: a hidden alias that runs grade-sweep Part B (task 1004471)', (t) => {
60
+ if (!skillsDirExists) return t.skip('.claude/skills not materialized in this checkout');
61
+ const md = readFileSync(ALIAS, 'utf8');
62
+ assert.match(md, /^name: grade-audit$/m);
63
+ assert.match(md, /^disable-model-invocation: true$/m, 'hidden from the model\'s listing, typeable as /grade-audit');
64
+ assert.match(md, /grade-sweep\/SKILL\.md/, 'points at the merged skill by path');
65
+ assert.match(md, /Part B/, 'runs the audit half only');
66
+ assert.match(readFileSync(SKILL, 'utf8'), /^disable-model-invocation: true$/m, 'the merged grade skill is hidden too (owner ruling)');
50
67
  });
@@ -1,8 +1,10 @@
1
- // tests/skill_grader_health.mjs — the /grader-health skill exists and covers
2
- // the four health axes from the grader-hardening evaluation §6 (task 1002656):
3
- // outage runs, pass-rate drift, cost anomalies, and stale model pins — reading
4
- // LIVE data surfaces, emitting exact --regrade remediation lines, and staying
5
- // read-only (describe, never confirm/flip — ADR 0158 §2; no approval step on anyone's ship — ADR 0162).
1
+ // tests/skill_grader_health.mjs — the grader HEALTH check (once /grader-health, now
2
+ // Part A of the merged /grade-sweep — task 1004471) covers the four health axes from
3
+ // the grader-hardening evaluation §6 (task 1002656): outage runs, pass-rate drift,
4
+ // cost anomalies, and stale model pins — reading LIVE data surfaces, emitting exact
5
+ // --regrade remediation lines, and staying read-only (describe, never confirm/flip —
6
+ // ADR 0158 §2; no approval step on anyone's ship — ADR 0162). /grader-health stays
7
+ // typeable as a hidden alias.
6
8
  import assert from 'node:assert/strict';
7
9
  import { test } from 'node:test';
8
10
  import { readFileSync, existsSync } from 'node:fs';
@@ -10,16 +12,22 @@ import { fileURLToPath } from 'node:url';
10
12
  import path from 'node:path';
11
13
 
12
14
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13
- const SKILL = path.join(ROOT, '.claude', 'skills', 'grader-health', 'SKILL.md');
15
+ const SKILL = path.join(ROOT, '.claude', 'skills', 'grade-sweep', 'SKILL.md');
16
+ const ALIAS = path.join(ROOT, '.claude', 'skills', 'grader-health', 'SKILL.md');
14
17
 
15
18
  // .claude/skills is INSTANCE-materialized; a neutral core checkout carries none
16
19
  // (task 1002470) — skip there, same guard shape as file_map.mjs.
17
20
  const skillsDirExists = existsSync(path.join(ROOT, '.claude', 'skills'));
18
21
 
19
- test('/grader-health exists and covers the four health axes on live data', (t) => {
22
+ // Part A of the merged file: from its heading to Part B.
23
+ const partA = (md) => md.slice(md.indexOf('## Part A'), md.indexOf('## Part B'));
24
+
25
+ test('the grader health check (grade-sweep Part A) covers the four health axes on live data', (t) => {
20
26
  if (!skillsDirExists) return t.skip('.claude/skills not materialized in this checkout');
21
- assert.ok(existsSync(SKILL), '.claude/skills/grader-health/SKILL.md must exist (task 1002656)');
22
- const md = readFileSync(SKILL, 'utf8');
27
+ assert.ok(existsSync(SKILL), '.claude/skills/grade-sweep/SKILL.md must exist (task 1004471, was grader-health — task 1002656)');
28
+ const whole = readFileSync(SKILL, 'utf8');
29
+ const md = partA(whole);
30
+ assert.ok(md.length > 1000, 'Part A is present');
23
31
 
24
32
  // Live data surfaces, named concretely.
25
33
  assert.match(md, /\/api\/bongos\/public\/grades/, 'reads the public grade trend');
@@ -44,5 +52,15 @@ test('/grader-health exists and covers the four health axes on live data', (t) =
44
52
  assert.match(md, /--probe/, 'offers the optional live calibration probe');
45
53
  assert.match(md, /grade-smoke\.js/, 'names the probe script');
46
54
  assert.match(md, /read-only/i, 'states the read-only contract');
47
- assert.match(md, /ADR 0162/, 'anchors the no-approval-step-on-a-ship constraint');
55
+ assert.match(whole, /ADR 0162/, 'anchors the no-approval-step-on-a-ship constraint');
56
+ });
57
+
58
+ test('/grader-health still works when typed: a hidden alias that runs grade-sweep Part A (task 1004471)', (t) => {
59
+ if (!skillsDirExists) return t.skip('.claude/skills not materialized in this checkout');
60
+ const md = readFileSync(ALIAS, 'utf8');
61
+ assert.match(md, /^name: grader-health$/m);
62
+ assert.match(md, /^disable-model-invocation: true$/m, 'hidden from the model\'s listing, typeable as /grader-health');
63
+ assert.match(md, /grade-sweep\/SKILL\.md/, 'points at the merged skill by path');
64
+ assert.match(md, /Part A/, 'runs the health half only');
65
+ assert.match(md, /--probe/, 'passes --probe through');
48
66
  });
@@ -138,6 +138,43 @@ test('the listing counts only RESIDENT skills: an off module\'s skill is linted
138
138
  fs.rmSync(root, { recursive: true, force: true });
139
139
  });
140
140
 
141
+ test('a HIDDEN skill (disable-model-invocation: true) is linted but not counted toward the listing budget, and is reported on its own (task 1004471)', () => {
142
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-lint-hidden-'));
143
+ const put = (rel, text) => { const p = path.join(root, rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, text); };
144
+ const skill = (n, extra = '') => `---\nname: ${n}\ndescription: >-\n ${'y'.repeat(100)}\n${extra}---\nbody\n`;
145
+ put('.claude/skills/shown/SKILL.md', skill('shown'));
146
+ put('.claude/skills/tucked/SKILL.md', skill('tucked', 'disable-model-invocation: true\n'));
147
+ put('.claude/skills/false-flag/SKILL.md', skill('false-flag', 'disable-model-invocation: false\n'));
148
+ const files = lint.listSkillFiles(root);
149
+ const report = lint.lintSkills(files, { resident: lint.residentSkillFiles(root, { files }) });
150
+ assert.equal(report.results.length, 3, 'all three are linted');
151
+ assert.equal(report.listedSkills, 2, 'only a literal true hides — false stays listed');
152
+ assert.equal(report.listingChars, ('shown'.length + 100) + ('false-flag'.length + 100));
153
+ assert.equal(report.hiddenSkills, 1);
154
+ assert.equal(report.hiddenChars, 'tucked'.length + 100);
155
+ assert.deepEqual(report.hiddenNames, ['tucked']);
156
+ const out = lint.format(report, { relTo: root });
157
+ assert.match(out, /listing 215 chars over 2 listed skill/);
158
+ assert.match(out, /1 hidden skill\(s\).*not counted\): 106 chars — tucked/);
159
+ fs.rmSync(root, { recursive: true, force: true });
160
+ });
161
+
162
+ test('a hidden skill is exempt from the long-description warning (it costs no listing) but not from the hard cap', () => {
163
+ const long = 'z'.repeat(lint.DESC_WARN_CHARS + 10);
164
+ assert.equal(run(fm(long, 'disable-model-invocation: true\n')).warnings.length, 0);
165
+ assert.equal(run(fm(long)).warnings.length, 1, 'the same text listed still warns');
166
+ assert.equal(run(fm('z'.repeat(lint.DESC_HARD_CHARS + 1), 'disable-model-invocation: true\n')).errors.length, 1);
167
+ assert.equal(lint.isHidden({ 'disable-model-invocation': 'TRUE ' }), true);
168
+ assert.equal(lint.isHidden({ 'disable-model-invocation': 'yes' }), false, 'anything but true leaves it listed');
169
+ });
170
+
171
+ test('the real default listing is under the ~8,000-char budget once hidden skills drop out (task 1004471)', () => {
172
+ const files = lint.listSkillFiles(ROOT);
173
+ const report = lint.lintSkills(files, { resident: lint.residentSkillFiles(ROOT, { files }) });
174
+ assert.equal(report.overBudget, false, `listing is ${report.listingChars} chars over ${report.listedSkills} listed skills`);
175
+ assert.ok(report.hiddenSkills >= 12, `the owner's hide list is in effect (${report.hiddenSkills} hidden)`);
176
+ });
177
+
141
178
  test('every committed skill parses (the ten that did not on 2026-09-03 must stay fixed)', () => {
142
179
  const report = lint.lintSkills(lint.listSkillFiles(ROOT));
143
180
  const broken = report.results.filter((r) => r.errors.length).map((r) => path.relative(ROOT, r.file) + ': ' + r.errors.join('; '));
@@ -0,0 +1,99 @@
1
+ // tests/skill_menu_ruling.mjs — the owner's skill-menu ruling (task 1004471, blocker
2
+ // 1000139) holds: which skills the model is shown, which are hidden but still typeable
3
+ // as /name, and the three merges (/owner-review, /goal-close, /grade-sweep) with every
4
+ // old command still answering. Pinned skill by skill, so a later edit that re-lists a
5
+ // hidden skill, hides a safety net, or breaks an old /name fails here by name.
6
+ import assert from 'node:assert/strict';
7
+ import { test } from 'node:test';
8
+ import { createRequire } from 'node:module';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
15
+ const lint = require('../scripts/gds/skill-lint.js');
16
+
17
+ const byName = new Map(lint.listSkillFiles(ROOT).map((f) => [path.basename(path.dirname(f)), f]));
18
+ const read = (n) => fs.readFileSync(byName.get(n), 'utf8');
19
+ const info = (n) => lint.lintSkillFile(read(n), { file: byName.get(n) });
20
+
21
+ // The ruling, as written into the task.
22
+ const LISTED_TRIMMED = ['grade-recover', 'collab-review', 'session-handoff', 'builder-claim', 'builder-sequence', 'blocker-solve', 'design', 'goal-create', 'blocker-review', 'builder-reauth', 'read-session-export', 'recall', 'worktree-clean', 'merge-mode', 'builder-setup', 'builder-key', 'bongos-feedback', 'feedback', 'builder-redteam', 'scan-before-install', 'owner-review'];
23
+ const LISTED_AS_IS = ['strand-fix', 'builder-ship', 'builder-start', 'fix-task', 'ask-for-help', 'ship-check', 'status', 'builder-end', 'builder-release', 'builder-stage'];
24
+ const HIDDEN = ['planning-session', 'goal-close', 'grade-sweep', 'builder-exit', 'builder-backup', 'builder-cost', 'new-project', 'design-sync', 'figma-design-sync', 'tweak'];
25
+ const ALIASES = ['backlog-review', 'bug-triage', 'idea-triage', 'priority-session', 'goal-uat', 'goal-review', 'grade-audit', 'grader-health'];
26
+
27
+ test('every skill the ruling keeps listed is present, listed, and (where trimmed) at most 250 chars', () => {
28
+ for (const n of [...LISTED_TRIMMED, ...LISTED_AS_IS]) {
29
+ assert.ok(byName.has(n), `${n} exists`);
30
+ const r = info(n);
31
+ assert.equal(r.hidden, false, `${n} must stay in the model's listing`);
32
+ assert.deepEqual(r.errors, [], `${n} parses`);
33
+ }
34
+ const over = LISTED_TRIMMED.filter((n) => info(n).description.length > 250).map((n) => `${n} ${info(n).description.length}`);
35
+ assert.deepEqual(over, [], 'trimmed descriptions are <= 250 chars');
36
+ });
37
+
38
+ test('every skill the ruling hides — and every old name a merge replaced — is hidden but still a typeable skill', () => {
39
+ for (const n of [...HIDDEN, ...ALIASES]) {
40
+ assert.ok(byName.has(n), `/${n} still exists, so typing it still works`);
41
+ assert.equal(info(n).hidden, true, `${n} carries disable-model-invocation: true`);
42
+ }
43
+ });
44
+
45
+ test('the safety nets and owner-pinned triggers survive the trim', () => {
46
+ const d = (n) => info(n).description;
47
+ assert.match(d('builder-redteam'), /"I found a security issue"/);
48
+ assert.match(d('scan-before-install'), /"is this plugin safe to install"/);
49
+ assert.match(d('builder-reauth'), /"Bongos CLI session is no longer valid"/, 'fires on the CLI\'s own error string');
50
+ assert.match(d('builder-setup'), /"no Bongos session"/, 'fires on the CLI\'s own error string');
51
+ assert.match(d('builder-key'), /never accept a key pasted in chat/, 'keeps the hard rule in the listing');
52
+ assert.match(d('bongos-feedback'), /unasked/, 'keeps its unprompted trigger');
53
+ for (const n of [...LISTED_TRIMMED, ...LISTED_AS_IS, ...HIDDEN, ...ALIASES]) {
54
+ const fm = read(n).split('\n---')[0];
55
+ for (const k of ['plain', 'reach-for', 'cost']) assert.match(fm, new RegExp(`^${k}:`, 'm'), `${n} keeps its ${k}: line (the hall Commands page reads it)`);
56
+ }
57
+ });
58
+
59
+ test('/owner-review is the listed front door to backlog -> bugs -> ideas -> priorities, and plain words open each step', () => {
60
+ const md = read('owner-review');
61
+ const desc = info('owner-review').description;
62
+ for (const phrase of ['"review the backlog"', '"triage bugs"', '"triage ideas"', '"review the inbox"']) assert.ok(desc.includes(phrase), `listing routes ${phrase}`);
63
+ const order = ['backlog-review', 'bug-triage', 'idea-triage', 'priority-session'].map((s) => md.indexOf(`.claude/skills/${s}/SKILL.md`));
64
+ assert.ok(order.every((i) => i > 0), 'each step names its playbook by path');
65
+ assert.deepEqual([...order].sort((a, b) => a - b), order, 'in the ruling\'s order');
66
+ for (const step of ['backlog', 'bugs', 'ideas', 'priorities']) assert.match(md, new RegExp('`' + step + '`'), `/owner-review ${step} opens one step`);
67
+ assert.match(md, /Read that step's `SKILL\.md` with the Read tool/, 'the model reads a hidden step by path — the Skill tool cannot load it');
68
+ for (const s of ['backlog-review', 'bug-triage', 'idea-triage', 'priority-session']) assert.match(md, new RegExp('`/' + s + '`'), `/${s} is named as still typeable`);
69
+ });
70
+
71
+ test('the nightly idea-triage routine still finds the playbook it reads by path', () => {
72
+ const nightly = fs.readFileSync(path.join(ROOT, '.claude/scheduled-tasks/idea-triage-nightly/SKILL.md'), 'utf8');
73
+ assert.match(nightly, /\.claude\/skills\/idea-triage\/SKILL\.md/);
74
+ assert.ok(fs.existsSync(path.join(ROOT, '.claude/skills/idea-triage/SKILL.md')));
75
+ assert.ok(read('idea-triage').length > 5000, 'the full playbook, not an alias stub');
76
+ });
77
+
78
+ test('/goal-close carries both halves; /goal-uat and /goal-review run one each', () => {
79
+ const md = read('goal-close');
80
+ const p1 = md.indexOf('## Part 1'), p2 = md.indexOf('## Part 2');
81
+ assert.ok(p1 > 0 && p2 > p1, 'UAT part, then residue part');
82
+ assert.match(md.slice(p1, p2), /uat\.js --queue/, 'Part 1 reads the UAT queue');
83
+ assert.match(md.slice(p1, p2), /--recording/, 'Part 1 signs off with a recording');
84
+ assert.match(md.slice(p2), /done-when\/pending-review/, 'Part 2 reads the review queue');
85
+ assert.match(md.slice(p2), /override_reason/, 'Part 2 overrides only with a written reason');
86
+ assert.match(md, /xenos/, 'one Metic+ gate at the top');
87
+ assert.match(read('goal-uat'), /goal-close\/SKILL\.md[\s\S]*Part 1/);
88
+ assert.match(read('goal-review'), /goal-close\/SKILL\.md[\s\S]*Part 2/);
89
+ });
90
+
91
+ test('/tweak lives in the copy-desk module, which contributes it (default-on, so it stays installed)', () => {
92
+ assert.ok(fs.existsSync(path.join(ROOT, 'modules/copy-desk/skills/tweak/SKILL.md')));
93
+ assert.ok(!fs.existsSync(path.join(ROOT, '.claude/skills/tweak/SKILL.md')) || !require('node:child_process').execFileSync('git', ['ls-files', '.claude/skills/tweak'], { cwd: ROOT, encoding: 'utf8' }).trim(), 'no tracked core copy (an untracked materialised one is fine)');
94
+ const mj = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/copy-desk/module.json'), 'utf8'));
95
+ assert.deepEqual(mj.contributes.skills, ['tweak']);
96
+ assert.equal(mj.default, true);
97
+ const files = lint.listSkillFiles(ROOT);
98
+ assert.ok(lint.residentSkillFiles(ROOT, { files }).some((f) => path.basename(path.dirname(f)) === 'tweak'), 'resident on a default instance');
99
+ });