@bongos/core 1.20.41 → 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 (59) hide show
  1. package/.bongos-core.json +94 -64
  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/architecture.md +1 -1
  39. package/docs/file-map.md +12 -10
  40. package/docs/module-api-changelog.md +4 -0
  41. package/docs/onboarding/slash-commands.md +17 -11
  42. package/docs/packs/artist.md +1 -1
  43. package/docs/packs/engineer.md +4 -5
  44. package/modules/copy-desk/module.json +1 -1
  45. package/{.claude → modules/copy-desk}/skills/tweak/SKILL.md +1 -0
  46. package/modules/lifecycle/dependency-advisory.js +2 -2
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/release-notes.json +12 -0
  50. package/scripts/gds/fitness-ratchets.js +2 -0
  51. package/scripts/gds/module-artifact.js +1 -1
  52. package/scripts/gds/module-assess-security.js +251 -0
  53. package/scripts/gds/skill-lint.js +32 -9
  54. package/src/module-api.js +1 -1
  55. package/tests/module_assess_security.mjs +216 -0
  56. package/tests/skill_grade_audit.mjs +34 -17
  57. package/tests/skill_grader_health.mjs +28 -10
  58. package/tests/skill_lint.mjs +37 -0
  59. 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.41",
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.41",
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.41",
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",
@@ -8355,5 +8355,17 @@
8355
8355
  "id": "1004470",
8356
8356
  "text": "Projects that don't do pixel art or design styling no longer carry 18 unused AI commands in every session (about a third less to load); projects that want them turn them on with one setting."
8357
8357
  }
8358
+ ],
8359
+ "1.20.42": [
8360
+ {
8361
+ "id": "1003792",
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
+ }
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
+ }
8358
8370
  ]
8359
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) {
@@ -216,4 +216,4 @@ async function verifyModuleArtifact(tgz, { key, maxBytes = MAX_TARBALL_BYTES, in
216
216
  return out;
217
217
  }
218
218
 
219
- module.exports = { MAX_TARBALL_BYTES, packModule, verifyModuleArtifact };
219
+ module.exports = { MAX_TARBALL_BYTES, packModule, verifyModuleArtifact, deniedFiles };
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/module-assess-security.js — the Security part of a module's
3
+ // assessment: a pass/fail GATE over one published store version, recorded as a
4
+ // module_assessment_signals row (task 1003792; ADR 0343 D2; table from core_265).
5
+ //
6
+ // WHY a gate and not a score. ADR 0343 D2: a version that fails the security
7
+ // check is not listed, whatever else it scores, and security is never averaged
8
+ // in — so a module cannot buy back a vulnerability with good tests. The outcome
9
+ // is therefore only ever passed / failed, or not_scored when the check could not
10
+ // be completed (which is NOT a pass: a gate that cannot run must not open).
11
+ //
12
+ // The check reuses what the project already enforces rather than re-typing it:
13
+ // 1. the publish denylist — module-artifact.js deniedFiles (the repo-wide
14
+ // matchesDeny list + credential-named files; ADR 0107 §4). Publish already
15
+ // refuses these; re-checking here means a rule added later still reaches
16
+ // versions published before it.
17
+ // 2. dependency SOURCES — every entry in module.json `dependencies` (ADR 0138)
18
+ // must be a plain registry package name with a plain semver range or dist-
19
+ // tag. That is an ALLOWLIST (registrySpec below), deliberately stricter than
20
+ // a list of bad protocols: a git/http/file, aliased or GitHub-shorthand spec
21
+ // — or anything else unrecognised — installs code from somewhere nobody
22
+ // vetted, and fails the gate. (It mirrors the artifact-scan floor's grading,
23
+ // but a core script may not require a module's files, so it is its own
24
+ // small rule.) Floating ranges and wildcards are only noted.
25
+ // 3. KNOWN VULNERABILITIES in those dependencies — only when every spec passed
26
+ // check 2, so nothing a module names is ever fetched from a place of its
27
+ // choosing and no `file:` path is ever resolved on this host. npm resolves the ranges in a
28
+ // throwaway directory (--package-lock-only --ignore-scripts: registry
29
+ // metadata only, no package code downloaded or run), then `npm audit`; any
30
+ // high or critical advisory fails the gate (the threshold scripts/security/
31
+ // dep-audit.js uses for the core, whose parser this reuses).
32
+ // And one input that is reported, never failed on: the `maintenance` posture
33
+ // (ADR 0166). "Undeclared", "deprecated" or "orphaned" is something a buyer
34
+ // should see, but it is upkeep, not a vulnerability — that call is recorded in
35
+ // the row's detail so the hall (task 1003799) can surface it.
36
+ //
37
+ // Nothing here executes the module's own code, so unlike module-assess-tests.js
38
+ // this is safe to run on the control plane.
39
+ //
40
+ // node scripts/gds/module-assess-security.js <store_module_versions.id> run + record
41
+ // node scripts/gds/module-assess-security.js --dir modules/<key> dry run (prints; no DB)
42
+
43
+ const fs = require('node:fs');
44
+ const os = require('node:os');
45
+ const path = require('node:path');
46
+ const { execFile } = require('node:child_process');
47
+ const { deniedFiles, verifyModuleArtifact } = require('./module-artifact');
48
+ const { extractNpmFindings } = require('../security/dep-audit');
49
+
50
+ const AUDIT_TIMEOUT_MS = 120_000;
51
+ // npm is a .cmd shim on Windows, which execFile cannot start without a shell; go
52
+ // through cmd.exe explicitly rather than shell:true (the args are fixed constants).
53
+ const npmCommand = (args) => (process.platform === 'win32'
54
+ ? ['cmd.exe', ['/d', '/s', '/c', 'npm', ...args]]
55
+ : ['npm', args]);
56
+
57
+ // Run one npm command in `cwd`; resolve { ok, stdout, stderr, error }. Never rejects.
58
+ function npmRun(args, cwd, { exec = execFile, timeoutMs = AUDIT_TIMEOUT_MS } = {}) {
59
+ return new Promise((resolve) => {
60
+ const [cmd, argv] = npmCommand(args);
61
+ exec(cmd, argv, { cwd, timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024, windowsHide: true },
62
+ (err, stdout, stderr) => resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || '') }));
63
+ });
64
+ }
65
+
66
+ // Audit a dependency map { name: range } for known high/critical advisories.
67
+ // Resolves { ok: true, findings } or { ok: false, reason } when npm could not
68
+ // resolve or audit (offline, unknown package, npm missing) — the caller treats
69
+ // that as not_scored, never as a pass.
70
+ async function auditDependencies(deps, { exec, timeoutMs } = {}) {
71
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'module-assess-security-'));
72
+ try {
73
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'module-assess-audit', private: true, version: '0.0.0', dependencies: deps }));
74
+ const lock = await npmRun(['install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--no-fund'], dir, { exec, timeoutMs });
75
+ if (lock.err || !fs.existsSync(path.join(dir, 'package-lock.json'))) {
76
+ return { ok: false, reason: 'npm could not resolve the declared dependencies' };
77
+ }
78
+ // npm audit exits 1 when it finds anything; the JSON on stdout is the answer either way.
79
+ const audit = await npmRun(['audit', '--json'], dir, { exec, timeoutMs });
80
+ let parsed;
81
+ try { parsed = JSON.parse(audit.stdout || ''); } catch { return { ok: false, reason: 'npm audit produced no readable report' }; }
82
+ if (parsed && parsed.error) return { ok: false, reason: 'npm audit could not reach the advisory database' };
83
+ return { ok: true, findings: extractNpmFindings(parsed) };
84
+ } finally {
85
+ fs.rmSync(dir, { recursive: true, force: true });
86
+ }
87
+ }
88
+
89
+ // npm's package-name rule (scoped or not, lowercase, no path segments).
90
+ const NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
91
+ // A semver range: versions, x/* wildcards, ^ ~ comparators, hyphen and || unions,
92
+ // prerelease/build suffixes. No ':' '/' '#' '@' — so no protocol, path, alias or ref.
93
+ const RANGE_RE = /^[0-9A-Za-z.*^~<>=|\s+-]+$/;
94
+ const DIST_TAGS = new Set(['latest', 'next']);
95
+
96
+ // Grade one declared dependency. { ok: false } means it would install from
97
+ // somewhere other than the public registry (or cannot be read as a registry
98
+ // spec): the gate fails and it is never handed to npm. { ok: true, kind } is a
99
+ // registry spec; kind names how much the registry decides at install time.
100
+ function registrySpec(name, spec) {
101
+ const s = String(spec).trim();
102
+ if (!NPM_NAME_RE.test(name)) return { ok: false, kind: 'invalid package name' };
103
+ if (DIST_TAGS.has(s) || s === '*' || s === '' || /^[xX]$/.test(s)) return { ok: true, kind: 'wildcard' };
104
+ if (/^[a-z+]+:/i.test(s)) return { ok: false, kind: 'non-registry source' };
105
+ // Every whitespace/||-separated token must start with a comparator or a digit/x.
106
+ const tokens = s.split(/\s*\|\|\s*|\s+/).filter(Boolean);
107
+ if (!RANGE_RE.test(s) || !tokens.every((t) => t === '-' || /^(?:[\^~]|[<>]=?|=)?\s*[0-9xX*]/.test(t))) {
108
+ return { ok: false, kind: 'not a registry range' };
109
+ }
110
+ if (/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(s)) return { ok: true, kind: 'pinned' };
111
+ return { ok: true, kind: 'floating range' };
112
+ }
113
+
114
+ // The maintenance posture as a buyer would read it (ADR 0166).
115
+ function maintenancePosture(moduleJson) {
116
+ const m = moduleJson && moduleJson.maintenance;
117
+ return m && typeof m.status === 'string' ? m.status : 'undeclared';
118
+ }
119
+
120
+ // The gate over one version's files ([{ path, buf }], module-relative) and its
121
+ // parsed module.json. Returns the signal fields { outcome, score, sample_size,
122
+ // reason, detail }. `audit` is injectable so tests need no network.
123
+ async function assessSecurity(key, files, moduleJson, { audit = auditDependencies } = {}) {
124
+ const failures = [];
125
+ const notes = [];
126
+
127
+ const denied = deniedFiles(key, files.map((f) => f.path));
128
+ if (denied.length) failures.push({ check: 'denylist', files: denied });
129
+
130
+ const deps = (moduleJson && moduleJson.dependencies) || {};
131
+ const names = Object.keys(deps).sort();
132
+ const sources = [];
133
+ for (const name of names) {
134
+ const graded = registrySpec(name, deps[name]);
135
+ const entry = { package: name.slice(0, 214), spec: String(deps[name]).slice(0, 200), kind: graded.kind };
136
+ if (!graded.ok) sources.push(entry);
137
+ else if (graded.kind === 'wildcard') notes.push({ check: 'dependency_wildcard', ...entry });
138
+ else if (graded.kind === 'floating range') notes.push({ check: 'dependency_range', ...entry });
139
+ }
140
+ if (sources.length) failures.push({ check: 'dependency_source', dependencies: sources });
141
+
142
+ // Audit only an all-registry list: a failed source already fails the gate, and
143
+ // handing it to npm would make this host fetch (or resolve a file: path) of the
144
+ // module's choosing.
145
+ let advisories = [];
146
+ if (names.length && !sources.length) {
147
+ const a = await audit(deps);
148
+ if (!a.ok) {
149
+ return {
150
+ outcome: 'not_scored', score: null, sample_size: names.length,
151
+ reason: `the dependency audit could not run: ${a.reason}`,
152
+ detail: { failures, notes, maintenance: maintenancePosture(moduleJson) },
153
+ };
154
+ }
155
+ advisories = a.findings.map((f) => ({ package: f.package, severity: f.severity, id: f.id, summary: f.summary, fix: f.fix }));
156
+ if (advisories.length) failures.push({ check: 'vulnerable_dependency', advisories });
157
+ }
158
+
159
+ const maintenance = maintenancePosture(moduleJson);
160
+ if (maintenance !== 'maintained' && maintenance !== 'core-maintained') {
161
+ notes.push({ check: 'maintenance', status: maintenance });
162
+ }
163
+
164
+ const detail = { failures, notes, maintenance, dependencies_checked: names.length };
165
+ if (failures.length) {
166
+ return { outcome: 'failed', score: null, sample_size: names.length, reason: `failed: ${failures.map((f) => f.check).join(', ')}`, detail };
167
+ }
168
+ return {
169
+ outcome: 'passed', score: null, sample_size: names.length,
170
+ reason: names.length ? `passed: ${names.length} dependency(ies) from the registry, no high or critical advisory` : 'passed: no denied files and no dependencies',
171
+ detail,
172
+ };
173
+ }
174
+
175
+ // Append one Security row. Never UPDATEs (core_265 is append-only).
176
+ async function insertSecuritySignal(versionId, s, { db }) {
177
+ const { rows: [row] } = await db.query(
178
+ `INSERT INTO module_assessment_signals (version_id, part, outcome, score, sample_size, reason, detail)
179
+ VALUES ($1, 'security', $2, $3, $4, $5, $6)
180
+ RETURNING id, version_id, part, outcome, score, sample_size, reason, measured_at`,
181
+ [versionId, s.outcome, s.score, s.sample_size, s.reason, JSON.stringify(s.detail || {})]);
182
+ return row;
183
+ }
184
+
185
+ // Read and verify one published version, run the gate, record the result. A
186
+ // tarball that is missing or fails verification is not_scored — the gate could
187
+ // not be run, so it stays closed.
188
+ async function recordSecuritySignal(versionId, { db, storeDir, audit } = {}) {
189
+ const pool = db || require('../../src/bongos/pool').pool;
190
+ const { versionArtifactFile } = require('../../src/bongos/module-store');
191
+ const { rows: [ver] } = await pool.query(
192
+ 'SELECT id, module_key, version, artifact_path FROM store_module_versions WHERE id = $1', [versionId]);
193
+ if (!ver) return { ok: false, code: 'version_not_found', message: `no store_module_versions row ${versionId}` };
194
+
195
+ let signal;
196
+ try {
197
+ const tgz = await fs.promises.readFile(versionArtifactFile(ver, storeDir ? { dir: storeDir } : {}));
198
+ const v = await verifyModuleArtifact(tgz, { key: ver.module_key, includeFiles: true });
199
+ if (v.ok) {
200
+ signal = await assessSecurity(ver.module_key, v.files, v.moduleJson, { audit });
201
+ } else if (v.code === 'denied_content') {
202
+ signal = { outcome: 'failed', score: null, sample_size: null, reason: 'failed: denylist', detail: { failures: [{ check: 'denylist' }] } };
203
+ } else {
204
+ signal = { outcome: 'not_scored', score: null, sample_size: null, reason: `the tarball failed verification (${v.code})`, detail: {} };
205
+ }
206
+ } catch (e) {
207
+ signal = { outcome: 'not_scored', score: null, sample_size: null, reason: `the check could not run (${e.code || 'error'})`, detail: {} };
208
+ }
209
+ const row = await insertSecuritySignal(ver.id, signal, { db: pool });
210
+ return { ok: true, version: ver, signal: row };
211
+ }
212
+
213
+ function readModuleDir(dir) {
214
+ const out = [];
215
+ const walk = (rel) => {
216
+ for (const e of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) {
217
+ if (e.name === 'node_modules' || e.name === '.git') continue;
218
+ const r = rel ? `${rel}/${e.name}` : e.name;
219
+ if (e.isDirectory()) walk(r);
220
+ else if (e.isFile()) out.push({ path: r, buf: fs.readFileSync(path.join(dir, r)) });
221
+ }
222
+ };
223
+ walk('');
224
+ return out;
225
+ }
226
+
227
+ async function main(argv) {
228
+ const i = argv.indexOf('--dir');
229
+ if (i !== -1) {
230
+ const dir = path.resolve(argv[i + 1] || '');
231
+ const moduleJson = JSON.parse(fs.readFileSync(path.join(dir, 'module.json'), 'utf8'));
232
+ console.log(JSON.stringify(await assessSecurity(moduleJson.key, readModuleDir(dir), moduleJson), null, 2));
233
+ return 0;
234
+ }
235
+ const id = argv[0];
236
+ if (!/^\d+$/.test(String(id || ''))) {
237
+ console.error('usage: node scripts/gds/module-assess-security.js <store_module_versions.id> | --dir modules/<key>');
238
+ return 2;
239
+ }
240
+ const res = await recordSecuritySignal(id);
241
+ if (!res.ok) { console.error(res.message); return 1; }
242
+ const s = res.signal;
243
+ console.log(`${res.version.module_key} ${res.version.version}: security ${s.outcome} — ${s.reason} (signal ${s.id})`);
244
+ return 0;
245
+ }
246
+
247
+ if (require.main === module) {
248
+ main(process.argv.slice(2)).then((code) => process.exit(code), (e) => { console.error(e.stack || e.message); process.exit(1); });
249
+ }
250
+
251
+ module.exports = { assessSecurity, auditDependencies, recordSecuritySignal, insertSecuritySignal, maintenancePosture, registrySpec };
@@ -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.41'; // 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');