@bongos/core 1.20.42 → 1.20.44

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 (68) hide show
  1. package/.bongos-core.json +99 -74
  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/api/openapi.json +1 -1
  39. package/docs/file-map.md +12 -10
  40. package/docs/module-api-changelog.md +4 -0
  41. package/docs/modules-contract.md +3 -0
  42. package/docs/onboarding/slash-commands.md +17 -11
  43. package/docs/packs/artist.md +1 -1
  44. package/docs/packs/engineer.md +4 -5
  45. package/docs/page-readings.json +21 -21
  46. package/modules/copy-desk/module.json +1 -1
  47. package/{.claude → modules/copy-desk}/skills/tweak/SKILL.md +1 -0
  48. package/modules/hall-ui/public/collab.css +8 -1
  49. package/modules/hall-ui/public/oversight.css +0 -3
  50. package/modules/lifecycle/dependency-advisory.js +2 -2
  51. package/package-lock.json +2 -2
  52. package/package.json +1 -1
  53. package/release-notes.json +16 -0
  54. package/scripts/gds/fitness-ratchets.js +2 -0
  55. package/scripts/gds/module-artifact.js +52 -2
  56. package/scripts/gds/module.js +57 -4
  57. package/scripts/gds/skill-lint.js +32 -9
  58. package/src/bongos/routes/modules.js +5 -4
  59. package/src/module-api.js +1 -1
  60. package/src/module-loader/manifest-schema.js +28 -0
  61. package/tests/hall_lead_row_home.mjs +21 -0
  62. package/tests/module_manifest.mjs +22 -0
  63. package/tests/module_store_publish.mjs +88 -4
  64. package/tests/module_store_publish_route.mjs +20 -1
  65. package/tests/skill_grade_audit.mjs +34 -17
  66. package/tests/skill_grader_health.mjs +28 -10
  67. package/tests/skill_lint.mjs +37 -0
  68. package/tests/skill_menu_ruling.mjs +99 -0
@@ -8,8 +8,8 @@
8
8
  //
9
9
  // bongos module new <key>
10
10
  // Scaffolds modules/<key>/ — a VALID, loader-discoverable module.json (M03),
11
- // the dir skeleton (routes/, migrations/, ui/), a sample route factory, and a
12
- // nested CLAUDE.md — with NO edit to any core file. The loader
11
+ // the dir skeleton (routes/, migrations/, ui/), a sample route factory, a
12
+ // HOWTO.md template for the store page (ADR 0347), and a nested CLAUDE.md — with NO edit to any core file. The loader
13
13
  // (src/module-loader/loader.js) discovers it; enabling it (config/modules.json
14
14
  // or env) mounts its routes behind the kernel's auth — the strangler endgame
15
15
  // where adding a feature is "drop a directory," not "edit routes.js + modules.js".
@@ -94,6 +94,12 @@ function readJson(p) {
94
94
  catch (e) { if (e.code === 'ENOENT') return null; throw e; }
95
95
  }
96
96
 
97
+ // A text file's contents, or null when it does not exist.
98
+ function readText(p) {
99
+ try { return fs.readFileSync(p, 'utf8'); }
100
+ catch (e) { if (e.code === 'ENOENT') return null; throw e; }
101
+ }
102
+
97
103
  function envPrefix() {
98
104
  const { FALLBACK_ENV_PREFIX } = require('../../src/instance-config');
99
105
  try { return require('../../src/branding').branding().envPrefix || FALLBACK_ENV_PREFIX; }
@@ -213,9 +219,39 @@ Put \`NNN_*.sql\` under \`migrations/\` and set \`contributes.migrations: true\`
213
219
  \`require('../../../src/module-api')\` — the doorway — is the ONLY core import
214
220
  allowed. No deep core imports, no sibling-module imports; cooperate through
215
221
  kernel seams (\`api.registerProvider\` / \`api.resolve\` / \`api.on\` / \`api.emit\`).
222
+
223
+ ## The how-to (\`HOWTO.md\`)
224
+ This CLAUDE.md is for AI sessions working *inside* the module. \`HOWTO.md\` is for the
225
+ person who installs it — the store shows it as the module's page (ADR 0347). It is
226
+ plain Markdown, so any AI or person can write it. \`bongos module publish\` refuses
227
+ the module until each of its five sections has some text; replace the prompts in
228
+ \`<!-- -->\` with real answers. An optional \`howto.artifactUrl\` in module.json can
229
+ link a Claude page as an extra — never instead of the file.
216
230
  `;
217
231
  }
218
232
 
233
+ // The HOWTO.md a fresh module starts from (ADR 0347 D2): every required section as a
234
+ // heading, with its prompt in an HTML comment. checkHowto strips comments, so an
235
+ // untouched scaffold fails the publish gate until the author writes each section.
236
+ function howtoTemplate(key, manifest) {
237
+ const { HOWTO_SECTIONS } = require('./module-artifact');
238
+ const prompts = {
239
+ 'What it does': 'A few sentences: what this module adds, and who it is for.',
240
+ 'Install and enable': `How to get it (\`bongos module install ${key}\`) and switch it on (hall Modules tab).`,
241
+ 'How to use it': 'Its commands, routes and hall surfaces, with one worked example.',
242
+ 'Configuration': 'Env vars and settings, with their defaults. "None." is a fine answer.',
243
+ 'Limits and known issues': 'What it does not do, and anything known to go wrong. "None known." is a fine answer.',
244
+ };
245
+ const sections = HOWTO_SECTIONS.map((name) => `## ${name}\n\n<!-- ${prompts[name]} -->\n`).join('\n');
246
+ return `# ${manifest.title} — how to use it
247
+
248
+ <!-- Written for the person who installs this module, or for any AI helping them.
249
+ The store shows this file as the module's page. Every section below must have
250
+ some text before \`bongos module publish\` will accept the module (ADR 0347). -->
251
+
252
+ ${sections}`;
253
+ }
254
+
219
255
  // Has the core advanced past a deprecated module's declared removal version?
220
256
  // `removeAfter: "1.12.0"` means "may be deleted once core is ABOVE 1.12.0".
221
257
  function isPastRemoveAfter(maintenance, core) {
@@ -283,6 +319,7 @@ function scaffoldModule({ key, modulesDir = MODULES_DIR, core = coreVersion(), f
283
319
  'module.json': JSON.stringify(manifest, null, 2) + '\n',
284
320
  [`routes/${key}.js`]: routeTemplate(key),
285
321
  'CLAUDE.md': claudeMdTemplate(key, manifest),
322
+ 'HOWTO.md': howtoTemplate(key, manifest),
286
323
  // git does not track empty dirs — keep the rest of the skeleton with placeholders.
287
324
  'migrations/.gitkeep': '',
288
325
  'ui/.gitkeep': '',
@@ -621,6 +658,7 @@ function cmdNew(args, { log = console.log, errlog = console.error } = {}) {
621
658
  log(` • credit it — fill in author/origin/maintainer in module.json (license defaults to AGPL-3.0)`);
622
659
  log(` • enable it — add "${key}": true to config/modules.json (or set <PREFIX>_MODULE_${ENVKEY}=1)`);
623
660
  log(` • build it — fill routes/${key}.js; add migrations/ + ui/ (see modules/${key}/CLAUDE.md)`);
661
+ log(` • explain it — fill in every section of modules/${key}/HOWTO.md (publish refuses it until you do)`);
624
662
  log(' • verify — bongos upgrade (checks coreVersion compatibility)');
625
663
  return 0;
626
664
  }
@@ -672,7 +710,7 @@ function cmdUpgrade(_args, { log = console.log, errlog = console.error, gather =
672
710
  return 0;
673
711
  }
674
712
 
675
- function cmdCheck(args, { log = console.log, errlog = console.error, check = checkModulePublishability, sign = recordSignOff } = {}) {
713
+ function cmdCheck(args, { log = console.log, errlog = console.error, check = checkModulePublishability, sign = recordSignOff, howtoCheck } = {}) {
676
714
  const key = args.find((a) => !a.startsWith('-'));
677
715
  if (!key) {
678
716
  errlog('usage: bongos module check <key> [--sign-off "Name <email>"]');
@@ -692,6 +730,13 @@ function cmdCheck(args, { log = console.log, errlog = console.error, check = che
692
730
  for (const f of result.findings) errlog(` ✗ ${f.kind}: ${f.detail}`);
693
731
  }
694
732
 
733
+ // The store's how-to gate (ADR 0347 D4), reported here so an author learns early.
734
+ // Advisory only: it gates `bongos module publish`, never an upstream submit.
735
+ const { checkHowto, HOWTO_FILE } = require('./module-artifact');
736
+ const howto = (howtoCheck || ((k) => checkHowto(readText(path.join(MODULES_DIR, k, HOWTO_FILE)))))(key);
737
+ if (howto.ok) log(` ✓ ${HOWTO_FILE} has every required section — ready for \`bongos module publish\`.`);
738
+ else for (const p of howto.problems) log(` ! ${p} — needed before \`bongos module publish\` (not for an upstream submit)`);
739
+
695
740
  if (!result.ok) {
696
741
  errlog(`\nREFUSING: "${key}" is not clear to submit upstream. Fix the finding(s) above and re-run.`);
697
742
  return 1;
@@ -814,6 +859,14 @@ async function cmdPublish(args, {
814
859
  catch (e) { errlog(`REFUSING: ${e.message}`); return 1; }
815
860
  const { tgz, sidecar } = packed;
816
861
  log(`bongos module publish — "${key}" ${sidecar.module_version} (needs core ${sidecar.core_version})`);
862
+ // The how-to gate (ADR 0347 D4) — the same checker the store re-runs on upload.
863
+ const howto = artifact.checkHowto(readText(path.join(modulesDir, key, artifact.HOWTO_FILE)));
864
+ if (!howto.ok) {
865
+ for (const p of howto.problems) errlog(` ✗ ${p}`);
866
+ errlog(`REFUSING: "${key}" needs a complete ${artifact.HOWTO_FILE} before it can be published — fill in the section(s) above.`);
867
+ return 1;
868
+ }
869
+ log(` ✓ ${artifact.HOWTO_FILE} has all ${artifact.HOWTO_SECTIONS.length} required sections`);
817
870
  log(` ✓ packed ${sidecar.file_count} file(s), ${tgz.length} bytes — tree ${sidecar.tree_sha256.slice(0, 12)}…`);
818
871
  if (tgz.length > artifact.MAX_TARBALL_BYTES) {
819
872
  errlog(`REFUSING: the tarball is ${tgz.length} bytes, over the store's ${artifact.MAX_TARBALL_BYTES}-byte cap.`);
@@ -1130,7 +1183,7 @@ async function cmdUpdate(args, {
1130
1183
  function printHelp(out = console.log) {
1131
1184
  out('bongos module — scaffold + manage Cloud Bongos modules\n');
1132
1185
  out('Usage:');
1133
- out(' bongos module new <key> Scaffold modules/<key>/ (manifest + skeleton + sample route + CLAUDE.md)');
1186
+ out(' bongos module new <key> Scaffold modules/<key>/ (manifest + skeleton + sample route + HOWTO.md + CLAUDE.md)');
1134
1187
  out(' bongos module list [--catalog] [--search <text>] [--json]');
1135
1188
  out(' Browse the module catalog — what you can enable, with author + origin credit');
1136
1189
  out(' bongos upgrade Pre-check enabled modules against the core version (fail-closed)');
@@ -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();
@@ -308,9 +308,10 @@ module.exports = function buildModulesRouter() {
308
308
  // the store (task 1004271, ADR 0338 D1). Body: the gzip tarball
309
309
  // `bongos module publish` builds (scripts/gds/module-artifact.js), sent as
310
310
  // application/gzip. The route trusts nothing but the bytes: it recomputes every
311
- // hash, re-runs the publish denylist and validates module.json itself
312
- // (verifyModuleArtifact), then keeps the tarball on the control plane's disk and
313
- // INSERTs the version row in one transaction (module-store.js). A key's first
311
+ // hash, re-runs the publish denylist, validates module.json and checks that
312
+ // HOWTO.md has its required sections (ADR 0347 D4) itself (verifyModuleArtifact),
313
+ // then keeps the tarball on the control plane's disk and INSERTs the version row
314
+ // in one transaction (module-store.js). A key's first
314
315
  // publish makes the caller its author; after that only the author may publish,
315
316
  // a delisted key takes nothing new, and a version is published once and never
316
317
  // changed.
@@ -333,7 +334,7 @@ module.exports = function buildModulesRouter() {
333
334
  return res.fail('empty_artifact', { status: 400, message: 'Send the tarball as the body, Content-Type: application/gzip.' });
334
335
  }
335
336
 
336
- const v = await moduleArtifact.verifyModuleArtifact(tgz, { key });
337
+ const v = await moduleArtifact.verifyModuleArtifact(tgz, { key, requireHowto: true });
337
338
  if (!v.ok) {
338
339
  return res.fail(v.code, { status: v.code === 'artifact_too_large' ? 413 : 422, message: v.message });
339
340
  }
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.44'; // 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');
@@ -53,6 +53,9 @@
53
53
  // // actually declares in `contributes`, and every entry must be
54
54
  // // one it actually lists there — a typo'd exemption is an error,
55
55
  // // never a silent pass.
56
+ // "howto": { // OPTIONAL. Extras beside the module's HOWTO.md (ADR 0347 D3).
57
+ // "artifactUrl": "https://claude.ai/…" // A Claude page with the same how-to, shown as an extra,
58
+ // }, // never graded and never required — the file is the how-to.
56
59
  // "provides": ["reward"], // OPTIONAL. seam PORTS this module registers a provider for.
57
60
  // "consumes": ["grade"], // OPTIONAL. seam ports this module resolves (required capabilities).
58
61
  // "prerequisites": { "modules": ["economy"] }, // OPTIONAL. other modules that must be enabled.
@@ -100,6 +103,17 @@ const ATTRIBUTION_KEYS = ['author', 'origin', 'license', 'maintainer'];
100
103
  // Deliberately NOT exported: the validator and its own error message are the only
101
104
  // readers, and an export nothing imports is dead weight the knip ratchet counts.
102
105
  const SPEND_KEYS = ['requiresPayer'];
106
+ // The `howto` block (ADR 0347 D3) — closed for the same reason; not exported either.
107
+ const HOWTO_KEYS = ['artifactUrl'];
108
+
109
+ // An https link on claude.ai (or a subdomain). Shape only: the store never fetches it.
110
+ function isClaudeArtifactUrl(v) {
111
+ if (!isStr(v)) return false;
112
+ let u;
113
+ try { u = new URL(v); } catch { return false; }
114
+ const host = u.hostname.toLowerCase();
115
+ return u.protocol === 'https:' && !u.username && !u.password && (host === 'claude.ai' || host.endsWith('.claude.ai'));
116
+ }
103
117
 
104
118
  const MAINTENANCE_STATUSES = ['core-maintained', 'maintained', 'deprecated', 'orphaned'];
105
119
  const MAINTENANCE_KEYS = ['status', 'since', 'removeAfter', 'successor', 'note'];
@@ -306,6 +320,20 @@ function validateManifest(obj) {
306
320
  }
307
321
  }
308
322
 
323
+ // --- optional how-to extras (ADR 0347 D3) ---
324
+ if ('howto' in obj) {
325
+ const h = obj.howto;
326
+ if (!isObj(h)) E('howto: must be an object (e.g. { "artifactUrl": "https://claude.ai/…" })');
327
+ else {
328
+ for (const k of Object.keys(h)) {
329
+ if (!HOWTO_KEYS.includes(k)) E(`howto.${k}: unknown key (allowed: ${HOWTO_KEYS.join(', ')})`);
330
+ }
331
+ if ('artifactUrl' in h && !isClaudeArtifactUrl(h.artifactUrl)) {
332
+ E('howto.artifactUrl: must be an https:// link on claude.ai when present');
333
+ }
334
+ }
335
+ }
336
+
309
337
  // --- optional seam declarations ---
310
338
  if ('provides' in obj && !isStrArray(obj.provides)) E('provides: must be an array of non-empty strings (port names)');
311
339
  if ('consumes' in obj && !isStrArray(obj.consumes)) E('consumes: must be an array of non-empty strings (port names)');
@@ -0,0 +1,21 @@
1
+ // .ov-row--lead is rendered only by the Collab page, so its grid rule lives in
2
+ // collab.css, not oversight.css (task 1003637).
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { readFileSync } from 'node:fs';
6
+
7
+ const read = (f) => readFileSync(new URL(`../modules/hall-ui/public/${f}`, import.meta.url), 'utf8');
8
+
9
+ test('oversight.css no longer defines .ov-row--lead', () => {
10
+ assert.doesNotMatch(read('oversight.css'), /\.ov-row--lead/);
11
+ });
12
+
13
+ test('collab.css owns the base rule and its 640px companion', () => {
14
+ const css = read('collab.css');
15
+ assert.match(css, /^\.ov-row--lead \{ grid-template-columns: auto minmax\(0, 1fr\) auto; \}/m);
16
+ assert.match(css, /@media \(max-width: 640px\) \{\s*\.ov-row--lead \{[^}]*\}\s*\.ov-row--lead > \.ov-row__right \{ grid-column: 2; \}/);
17
+ });
18
+
19
+ test('collab.js still renders the class, so the rule is not dead', () => {
20
+ assert.match(read('collab.js'), /ov-row--lead/);
21
+ });
@@ -370,3 +370,25 @@ test('every bundled module with a declarativeSeams block validates against its o
370
370
  }
371
371
  assert.deepEqual(bad, [], bad.join('\n'));
372
372
  });
373
+
374
+ // --- howto.artifactUrl (ADR 0347 D3, task 1004365) ---
375
+
376
+ test('howto.artifactUrl: optional, and an https claude.ai link is accepted', () => {
377
+ assert.equal(validateManifest(baseManifest()).valid, true);
378
+ for (const url of ['https://claude.ai/artifact/abc123', 'https://claude.ai/code/artifact/0b1c']) {
379
+ const r = validateManifest({ ...baseManifest(), howto: { artifactUrl: url } });
380
+ assert.equal(r.valid, true, `${url}: ${r.errors.join('; ')}`);
381
+ }
382
+ });
383
+
384
+ test('howto.artifactUrl: anything but an https claude.ai link is refused, as is an unknown key', () => {
385
+ for (const url of ['http://claude.ai/artifact/x', 'https://evil.example/claude.ai', 'https://claude.ai.evil.example/x',
386
+ 'https://user:pw@claude.ai/x', 'javascript:alert(1)', '', 42]) {
387
+ const r = validateManifest({ ...baseManifest(), howto: { artifactUrl: url } });
388
+ assert.equal(r.valid, false, String(url));
389
+ assert.ok(r.errors.some((e) => e.startsWith('howto.artifactUrl')), r.errors.join('; '));
390
+ }
391
+ assert.ok(validateManifest({ ...baseManifest(), howto: { artifactURL: 'https://claude.ai/x' } }).errors
392
+ .some((e) => e.startsWith('howto.artifactURL: unknown key')));
393
+ assert.equal(validateManifest({ ...baseManifest(), howto: 'https://claude.ai/x' }).valid, false);
394
+ });
@@ -27,12 +27,21 @@ function moduleJson(over = {}) {
27
27
  };
28
28
  }
29
29
 
30
- // A temp modules/ root holding one module. files: { relPath: content }.
30
+ // A how-to with all five required sections (ADR 0347 D2).
31
+ const HOWTO = [
32
+ '# Weather', '', '## What it does', 'Shows the weather.', '', '## Install and enable',
33
+ '`bongos module install weather`, then switch it on.', '', '## How to use it', 'Open /weather.', '',
34
+ '## Configuration', 'None.', '', '## Limits and known issues', 'None known.', '',
35
+ ].join('\n');
36
+
37
+ // A temp modules/ root holding one module. files: { relPath: content }; a null
38
+ // content leaves that file out.
31
39
  function fixture(files = {}, mj = moduleJson()) {
32
40
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-publish-'));
33
41
  const dir = path.join(root, mj.key);
34
- const all = { 'module.json': JSON.stringify(mj, null, 2), 'routes/weather.js': 'module.exports = () => null;\n', ...files };
42
+ const all = { 'module.json': JSON.stringify(mj, null, 2), 'routes/weather.js': 'module.exports = () => null;\n', 'HOWTO.md': HOWTO, ...files };
35
43
  for (const [rel, body] of Object.entries(all)) {
44
+ if (body === null) continue;
36
45
  fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
37
46
  fs.writeFileSync(path.join(dir, rel), body);
38
47
  }
@@ -54,12 +63,12 @@ function retar(tgz, edit) {
54
63
  test('pack: the tarball is the core release format — package/ root, inner manifest, a tree pin', async () => {
55
64
  const { tgz, manifest, sidecar } = pack(fixture());
56
65
  const names = format.readTar(tgz).map((e) => e.name).sort();
57
- assert.deepEqual(names, ['package/.bongos-module.json', 'package/module.json', 'package/routes/weather.js']);
66
+ assert.deepEqual(names, ['package/.bongos-module.json', 'package/HOWTO.md', 'package/module.json', 'package/routes/weather.js']);
58
67
  assert.equal(manifest.artifact, 'bongos-module');
59
68
  assert.equal(manifest.module_key, 'weather');
60
69
  assert.equal(manifest.module_version, '1.2.0');
61
70
  assert.equal(manifest.core_version, '^1.0.0');
62
- assert.equal(manifest.file_count, 2);
71
+ assert.equal(manifest.file_count, 3);
63
72
  assert.equal(manifest.tree_sha256, format.canonicalTreeHash(manifest.files));
64
73
  assert.equal(sidecar.tarball.sha256, format.sha256hex(tgz));
65
74
  assert.equal(sidecar.tarball.name, 'bongos-module-weather-1.2.0.tgz');
@@ -302,6 +311,81 @@ test('cli: uploads the packed bytes, and a store refusal is a failure exit with
302
311
  assert.ok(out.lines.some((l) => /HTTP 409/.test(l) && /already published/.test(l)));
303
312
  });
304
313
 
314
+ // ---- the how-to gate (ADR 0347, task 1004365) -----------------------------------
315
+
316
+ test('howto: a complete how-to passes, headings in any order and any case', () => {
317
+ assert.deepEqual(artifact.checkHowto(HOWTO), { ok: true, problems: [] });
318
+ const reordered = HOWTO.replace('## Configuration\nNone.\n', '').replace('# Weather', '# Weather\n## CONFIGURATION\nNone.');
319
+ assert.equal(artifact.checkHowto(reordered).ok, true);
320
+ });
321
+
322
+ test('howto: a missing file, a missing section and an empty section are each refused by name', () => {
323
+ assert.deepEqual(artifact.checkHowto(null).problems, ['HOWTO.md is missing — every store module ships one (ADR 0347)']);
324
+ assert.deepEqual(artifact.checkHowto(HOWTO.replace('## Configuration\nNone.\n', '')).problems,
325
+ ['HOWTO.md: section "Configuration" is missing']);
326
+ assert.deepEqual(artifact.checkHowto(HOWTO.replace('Open /weather.', '')).problems,
327
+ ['HOWTO.md: section "How to use it" is empty']);
328
+ });
329
+
330
+ test('howto: a prompt in a comment, a sub-heading alone, or a heading inside a code fence does not count', () => {
331
+ assert.deepEqual(artifact.checkHowto(HOWTO.replace('None known.', '<!-- say what goes wrong -->')).problems,
332
+ ['HOWTO.md: section "Limits and known issues" is empty']);
333
+ assert.deepEqual(artifact.checkHowto(HOWTO.replace('None known.', '### Later')).problems,
334
+ ['HOWTO.md: section "Limits and known issues" is empty']);
335
+ const fenced = HOWTO.replace('## Configuration\nNone.\n', '').replace('Open /weather.', 'Open /weather.\n```\n## Configuration\n```');
336
+ assert.deepEqual(artifact.checkHowto(fenced).problems, ['HOWTO.md: section "Configuration" is missing']);
337
+ });
338
+
339
+ test('howto: the scaffold\'s template has every section but fails the gate until it is written', () => {
340
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-howto-'));
341
+ moduleCli.scaffoldModule({ key: 'weather', modulesDir: root, core: '1.0.0' });
342
+ const text = fs.readFileSync(path.join(root, 'weather', 'HOWTO.md'), 'utf8');
343
+ const res = artifact.checkHowto(text);
344
+ assert.deepEqual(res.problems, artifact.HOWTO_SECTIONS.map((s) => `HOWTO.md: section "${s}" is empty`));
345
+ });
346
+
347
+ test('howto: the file is packed into the tarball, so its hash pins it to the version', async () => {
348
+ const v = await artifact.verifyModuleArtifact(pack(fixture()).tgz, { key: 'weather', requireHowto: true });
349
+ assert.equal(v.ok, true, v.message);
350
+ assert.ok(v.manifest.files.some((f) => f.path === 'HOWTO.md'));
351
+ });
352
+
353
+ test('howto: verify refuses an incomplete how-to only when asked, so older versions still install', async () => {
354
+ const tgz = pack(fixture({ 'HOWTO.md': null })).tgz;
355
+ assert.equal((await artifact.verifyModuleArtifact(tgz, { key: 'weather' })).ok, true, 'install/update verify is unchanged');
356
+ const v = await artifact.verifyModuleArtifact(tgz, { key: 'weather', requireHowto: true });
357
+ assert.equal(v.code, 'howto_incomplete');
358
+ assert.match(v.message, /HOWTO\.md is missing/);
359
+ });
360
+
361
+ test('cli: publish refuses a missing or incomplete how-to by name, before anything is uploaded', async () => {
362
+ for (const [files, re] of [
363
+ [{ 'HOWTO.md': null }, /HOWTO\.md is missing/],
364
+ [{ 'HOWTO.md': HOWTO.replace('## Configuration\nNone.\n', '') }, /section "Configuration" is missing/],
365
+ [{ 'HOWTO.md': HOWTO.replace('Shows the weather.', '') }, /section "What it does" is empty/],
366
+ ]) {
367
+ const out = sink();
368
+ let uploaded = false;
369
+ const code = await moduleCli.cmdPublish(['weather'], {
370
+ log: out.log, errlog: out.log, modulesDir: fixture(files), upload: async () => { uploaded = true; },
371
+ });
372
+ assert.equal(code, 1);
373
+ assert.equal(uploaded, false);
374
+ assert.ok(out.lines.some((l) => re.test(l)), out.lines.join('\n'));
375
+ }
376
+ });
377
+
378
+ test('cli: module check reports the how-to as advice and never fails an upstream check on it', () => {
379
+ const out = sink();
380
+ const code = moduleCli.cmdCheck(['weather'], {
381
+ log: out.log, errlog: out.log,
382
+ check: () => ({ ok: true, findings: [] }),
383
+ howtoCheck: () => artifact.checkHowto(null),
384
+ });
385
+ assert.equal(code, 0);
386
+ assert.ok(out.lines.some((l) => /HOWTO\.md is missing/.test(l) && /bongos module publish/.test(l)));
387
+ });
388
+
305
389
  test('cli: no key is a usage error', async () => {
306
390
  assert.equal(await moduleCli.cmdPublish([], { log: () => {}, errlog: () => {} }), 2);
307
391
  });
@@ -66,13 +66,17 @@ async function invoke(handle, req) {
66
66
  return res;
67
67
  }
68
68
 
69
- function packed(version = '1.0.0') {
69
+ // A how-to with all five required sections (ADR 0347 D2).
70
+ const HOWTO = artifact.HOWTO_SECTIONS.map((h) => `## ${h}\nSome text.\n`).join('\n');
71
+
72
+ function packed(version = '1.0.0', { howto = HOWTO } = {}) {
70
73
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-src-'));
71
74
  const dir = path.join(root, 'weather');
72
75
  fs.mkdirSync(dir);
73
76
  fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
74
77
  key: 'weather', title: 'Weather', description: 'd', version, coreVersion: '^1.0.0', contributes: {},
75
78
  }));
79
+ if (howto !== null) fs.writeFileSync(path.join(dir, 'HOWTO.md'), howto);
76
80
  return artifact.packModule('weather', { modulesDir: root, modeOf: () => '644' }).tgz;
77
81
  }
78
82
 
@@ -119,3 +123,18 @@ test('a tarball for another key, garbage, or no body is refused before the regis
119
123
  assert.equal((await invoke(route.handle, { params: { key: 'Bad_Key' }, body: packed(), builder: { id: 42 } })).body.error, 'bad_module_key');
120
124
  assert.equal(state.versions.length, before);
121
125
  });
126
+
127
+ test('an upload that skipped the CLI\'s how-to check is refused by the store, and nothing is kept', async () => {
128
+ const before = state.versions.length;
129
+ const missing = await invoke(route.handle, { params: { key: 'weather' }, body: packed('3.0.0', { howto: null }), builder: { id: 42 } });
130
+ assert.equal(missing.statusCode, 422);
131
+ assert.equal(missing.body.error, 'howto_incomplete');
132
+ assert.match(missing.body.message, /HOWTO\.md is missing/);
133
+ const empty = await invoke(route.handle, {
134
+ params: { key: 'weather' }, body: packed('3.0.0', { howto: HOWTO.replace('## Configuration\nSome text.', '## Configuration\n') }), builder: { id: 42 },
135
+ });
136
+ assert.equal(empty.body.error, 'howto_incomplete');
137
+ assert.match(empty.body.message, /section "Configuration" is empty/);
138
+ assert.equal(state.versions.length, before);
139
+ assert.ok(!fs.existsSync(path.join(STORE, 'weather', 'weather-3.0.0.tgz')));
140
+ });
@@ -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
  });