@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.
- package/.bongos-core.json +99 -74
- package/.claude/skills/backlog-review/SKILL.md +1 -0
- package/.claude/skills/blocker-review/SKILL.md +1 -1
- package/.claude/skills/bug-triage/SKILL.md +1 -0
- package/.claude/skills/builder-backup/SKILL.md +1 -0
- package/.claude/skills/builder-claim/SKILL.md +1 -1
- package/.claude/skills/builder-cost/SKILL.md +1 -0
- package/.claude/skills/builder-exit/SKILL.md +1 -0
- package/.claude/skills/builder-key/SKILL.md +1 -1
- package/.claude/skills/builder-reauth/SKILL.md +1 -1
- package/.claude/skills/builder-redteam/SKILL.md +1 -1
- package/.claude/skills/builder-sequence/SKILL.md +1 -1
- package/.claude/skills/builder-setup/SKILL.md +1 -1
- package/.claude/skills/collab-review/SKILL.md +1 -1
- package/.claude/skills/design/SKILL.md +1 -1
- package/.claude/skills/design-sync/SKILL.md +1 -0
- package/.claude/skills/feedback/SKILL.md +1 -1
- package/.claude/skills/figma-design-sync/SKILL.md +1 -0
- package/.claude/skills/goal-close/SKILL.md +184 -0
- package/.claude/skills/goal-create/SKILL.md +1 -1
- package/.claude/skills/goal-review/SKILL.md +5 -102
- package/.claude/skills/goal-uat/SKILL.md +5 -81
- package/.claude/skills/grade-audit/SKILL.md +6 -85
- package/.claude/skills/grade-recover/SKILL.md +1 -1
- package/.claude/skills/grade-sweep/SKILL.md +174 -0
- package/.claude/skills/grader-health/SKILL.md +5 -72
- package/.claude/skills/idea-triage/SKILL.md +1 -0
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/.claude/skills/new-project/SKILL.md +1 -0
- package/.claude/skills/owner-review/SKILL.md +40 -0
- package/.claude/skills/planning-session/SKILL.md +1 -0
- package/.claude/skills/priority-session/SKILL.md +1 -0
- package/.claude/skills/read-session-export/SKILL.md +1 -1
- package/.claude/skills/recall/SKILL.md +1 -1
- package/.claude/skills/scan-before-install/SKILL.md +1 -1
- package/.claude/skills/session-handoff/SKILL.md +1 -1
- package/.claude/skills/worktree-clean/SKILL.md +1 -1
- package/docs/api/openapi.json +1 -1
- package/docs/file-map.md +12 -10
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +3 -0
- package/docs/onboarding/slash-commands.md +17 -11
- package/docs/packs/artist.md +1 -1
- package/docs/packs/engineer.md +4 -5
- package/docs/page-readings.json +21 -21
- package/modules/copy-desk/module.json +1 -1
- package/{.claude → modules/copy-desk}/skills/tweak/SKILL.md +1 -0
- package/modules/hall-ui/public/collab.css +8 -1
- package/modules/hall-ui/public/oversight.css +0 -3
- package/modules/lifecycle/dependency-advisory.js +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/fitness-ratchets.js +2 -0
- package/scripts/gds/module-artifact.js +52 -2
- package/scripts/gds/module.js +57 -4
- package/scripts/gds/skill-lint.js +32 -9
- package/src/bongos/routes/modules.js +5 -4
- package/src/module-api.js +1 -1
- package/src/module-loader/manifest-schema.js +28 -0
- package/tests/hall_lead_row_home.mjs +21 -0
- package/tests/module_manifest.mjs +22 -0
- package/tests/module_store_publish.mjs +88 -4
- package/tests/module_store_publish_route.mjs +20 -1
- package/tests/skill_grade_audit.mjs +34 -17
- package/tests/skill_grader_health.mjs +28 -10
- package/tests/skill_lint.mjs +37 -0
- package/tests/skill_menu_ruling.mjs +99 -0
package/scripts/gds/module.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
|
194
|
-
|
|
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').
|
|
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
|
|
312
|
-
//
|
|
313
|
-
//
|
|
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.
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
// the override ledger, dropped
|
|
4
|
-
//
|
|
5
|
-
// outage roll-up handoff — describing and
|
|
6
|
-
// never confirms a task or flips status; ADR 0162: it
|
|
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-
|
|
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
|
-
|
|
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-
|
|
23
|
-
const
|
|
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\]/, '
|
|
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
|
|
44
|
-
assert.match(md,
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
assert.
|
|
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
|
});
|