SKILL.md had drifted from the global CLAUDE.md on two counts, both verified by grep before the rewrite: the STATE.md template predated the mandatory `board:` line and the `route:`/`route-last:` lines, and the closing line still demanded three fields where six are now required (Innboks, Modell neste okt, Oppstartskommando were missing). - New step 3 routes the next session via `repo-mailbox:route` BEFORE the Write. It cannot run after the commit: the emitted lines live inside STATE.md, so routing afterwards would dirty a file that was just committed. One invocation feeds both the three comment lines and the closing line's model fields. - `repo-mailbox` stays a soft dependency — documented fallback if it is absent or the cross-plugin Skill invocation is blocked. `route.sh`'s path is deliberately not hardcoded (plugin cache, versioned, drifts). - The single-line constraint on the three comments is now in prose: `board.sh` reads the first non-blank, non-heading, non-`<!--` line under the heading as the repo's next step, so a wrapped `rationale=` corrupts the board. - Closing line 3 -> 6 fields. The Innboks field reports what the session did rather than re-querying the mailbox — inbox handling belongs first in a session, and "no inbox injected" must never be reported as "empty". - STATE format consolidated to ONE copy. Repo CLAUDE.md restated it with the same defect; it now points at SKILL.md step 4 as the authority, following the model-rubric precedent (two copies drift, prose cannot be tested). - allowed-tools gains `Skill`. `plugin.json` description left unchanged on purpose — editing it would require the manual marketplace.json edit that release-plugin.mjs does not perform. Tests 30 -> 42, all green. They are prose greps: drift guards, not proof the ritual runs. Verifying that means a manual /graceful-handoff against a scratch repo. Release (tag + catalog ref bump) is operator-gated and NOT done here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013V59bNbWa5x2oTH2NMBJy4
147 lines
5.2 KiB
JavaScript
147 lines
5.2 KiB
JavaScript
// skill-structure.test.mjs — Verifies SKILL.md frontmatter (v3.0) and commands/ deletion.
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const PLUGIN_ROOT = join(__dirname, '..');
|
|
const SKILL = join(PLUGIN_ROOT, 'skills', 'graceful-handoff', 'SKILL.md');
|
|
const REPO_CLAUDE_MD = join(PLUGIN_ROOT, 'CLAUDE.md');
|
|
|
|
function skill() {
|
|
return readFileSync(SKILL, 'utf-8');
|
|
}
|
|
|
|
test('SKILL.md exists at expected path', () => {
|
|
assert.ok(existsSync(SKILL), `SKILL.md missing at ${SKILL}`);
|
|
});
|
|
|
|
test('commands/ directory is deleted (hard cut to skills/)', () => {
|
|
assert.ok(!existsSync(join(PLUGIN_ROOT, 'commands')), 'commands/ should be deleted');
|
|
});
|
|
|
|
test('hooks/ directory is deleted (v3.0 removed all hooks)', () => {
|
|
assert.ok(!existsSync(join(PLUGIN_ROOT, 'hooks')), 'hooks/ should be deleted in v3.0');
|
|
});
|
|
|
|
test('SKILL.md has disable-model-invocation: true', () => {
|
|
assert.match(skill(), /^disable-model-invocation: true$/m);
|
|
});
|
|
|
|
test('SKILL.md has NO model: pin (inherits session model for quality synthesis)', () => {
|
|
const fm = skill().match(/^---\n[\s\S]*?\n---/)[0];
|
|
assert.doesNotMatch(fm, /^model:/m);
|
|
});
|
|
|
|
test('SKILL.md allowed-tools is Bash sub-scoped and includes Write', () => {
|
|
const line = skill().match(/^allowed-tools:.*$/m);
|
|
assert.ok(line, 'allowed-tools line missing');
|
|
assert.match(line[0], /Bash\(git:\*\)/);
|
|
assert.match(line[0], /Bash\(node:\*\)/);
|
|
assert.match(line[0], /\bWrite\b/);
|
|
});
|
|
|
|
test('SKILL.md does not pre-approve curl or wget', () => {
|
|
const line = skill().match(/^allowed-tools:.*$/m);
|
|
assert.ok(line, 'allowed-tools line missing');
|
|
assert.doesNotMatch(line[0], /\bcurl\b/);
|
|
assert.doesNotMatch(line[0], /\bwget\b/);
|
|
});
|
|
|
|
test('SKILL.md body references handoff-pipeline.mjs', () => {
|
|
assert.match(skill(), /handoff-pipeline\.mjs/);
|
|
});
|
|
|
|
test('SKILL.md body mandates the 👉 NESTE — START HER block', () => {
|
|
assert.match(skill(), /👉 NESTE — START HER/);
|
|
});
|
|
|
|
test('SKILL.md body has Tidsbudsjett (time budget) note', () => {
|
|
assert.match(skill(), /Tidsbudsjett/);
|
|
});
|
|
|
|
test('SKILL.md is STATE.md-centric (overwrites the nearest STATE.md)', () => {
|
|
const s = skill();
|
|
assert.match(s, /STATE\.md/);
|
|
assert.match(s, /overskriv/i);
|
|
});
|
|
|
|
// --- v3.2.0: the ritual matches the global CLAUDE.md session-end mechanism ---
|
|
|
|
test('SKILL.md allowed-tools includes Skill (needed to invoke repo-mailbox:route)', () => {
|
|
const line = skill().match(/^allowed-tools:.*$/m);
|
|
assert.ok(line, 'allowed-tools line missing');
|
|
assert.match(line[0], /\bSkill\b/);
|
|
});
|
|
|
|
test('SKILL.md NESTE template carries all three STATE header comment lines', () => {
|
|
const s = skill();
|
|
assert.match(s, /<!-- board: status=/);
|
|
assert.match(s, /<!-- route: path=/);
|
|
assert.match(s, /<!-- route-last: model=/);
|
|
});
|
|
|
|
test('SKILL.md board line template uses the closed status token set', () => {
|
|
assert.match(skill(), /planned.*in-progress.*blocked.*deferred.*done/);
|
|
});
|
|
|
|
test('SKILL.md warns that the three comment lines must stay single-line (board.sh)', () => {
|
|
const s = skill();
|
|
assert.match(s, /board\.sh/);
|
|
assert.match(s, /ÉN linje/);
|
|
});
|
|
|
|
test('SKILL.md routes the next session via the repo-mailbox route skill', () => {
|
|
assert.match(skill(), /repo-mailbox:route/);
|
|
});
|
|
|
|
test('the route step runs BEFORE STATE.md is written (lines are spliced into the file)', () => {
|
|
const s = skill();
|
|
const routeStep = s.indexOf('repo-mailbox:route');
|
|
const writeStep = s.indexOf('UFRAVIKELIG FORMAT');
|
|
assert.ok(routeStep > -1 && writeStep > -1, 'both steps must exist');
|
|
assert.ok(
|
|
routeStep < writeStep,
|
|
'route must be scored before the STATE.md Write, else the commit is dirtied afterwards'
|
|
);
|
|
});
|
|
|
|
test('SKILL.md documents a fallback when repo-mailbox is not installed', () => {
|
|
assert.match(skill(), /ikke installert/);
|
|
});
|
|
|
|
test('SKILL.md closing line requires all six mandatory fields', () => {
|
|
const s = skill();
|
|
for (const field of [
|
|
'STATE.md:',
|
|
'Innboks:',
|
|
'Neste sesjon:',
|
|
'Commit/push:',
|
|
'Modell neste økt:',
|
|
'Oppstartskommando:',
|
|
]) {
|
|
assert.ok(s.includes(`**${field}**`), `closing-line field missing: ${field}`);
|
|
}
|
|
});
|
|
|
|
test('SKILL.md no longer claims the closing line has three items', () => {
|
|
assert.doesNotMatch(skill(), /eksakt tre ting/);
|
|
});
|
|
|
|
test('SKILL.md forbids asserting an empty inbox that was never seen', () => {
|
|
assert.match(skill(), /Innboks/);
|
|
assert.match(skill(), /ingen innboks ble injisert/i);
|
|
});
|
|
|
|
test('repo CLAUDE.md points at SKILL.md as the STATE-format authority (one copy)', () => {
|
|
const c = readFileSync(REPO_CLAUDE_MD, 'utf-8');
|
|
const section = c.match(/## STATE\.md-format[\s\S]*?(?=\n## )/);
|
|
assert.ok(section, 'STATE.md-format section missing from repo CLAUDE.md');
|
|
assert.match(section[0], /SKILL\.md/, 'section must name SKILL.md as the authority');
|
|
assert.match(section[0], /Ikke gjenopprett/, 'section must forbid restoring a second copy');
|
|
// The template must NOT be restated here — two copies drift (the model-rubric precedent).
|
|
assert.doesNotMatch(section[0], /# STATE — </, 'the template itself must not reappear here');
|
|
});
|