Skip to content
16 changes: 16 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ Template (with routing clarity for High score):
---
name: skill-name
description: "**WORKFLOW SKILL** — [ACTION VERB] [UNIQUE_DOMAIN]. [Clarifying sentence]. WHEN: \"[phrase1]\", \"[phrase2]\", \"[phrase3]\". INVOKES: [tools/MCP servers used]. FOR SINGLE OPERATIONS: [when to bypass this skill]."
allowed-tools: shell, read, write # optional: auto-allowed tools
user-invocable: true # optional: default true
disable-model-invocation: false # optional: default false
---
```

Expand Down Expand Up @@ -267,6 +270,19 @@ description: "**WORKFLOW SKILL** — Extract, rotate, merge, and split PDF files

See [references/examples.md](references/examples.md) for more before/after transformations.

### Reference-Only Pattern

Use `user-invocable: false` + `disable-model-invocation: true` to create a skill that acts as a shared reference file (loaded from disk, never invoked):

```yaml
---
name: team-conventions
description: "Shared coding conventions loaded as context."
user-invocable: false
disable-model-invocation: true
---
```

## Commit Messages

```
Expand Down
29 changes: 28 additions & 1 deletion references/scoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,18 @@ These checks are programmatic and run via `npm run tokens -- score`. They valida
| Check | What it validates | Spec rule |
|-------|-------------------|-----------|
| `spec-frontmatter` | YAML frontmatter exists, `name` and `description` present | Required fields |
| `spec-allowed-fields` | No unknown fields (only `name`, `description`, `license`, `allowed-tools`, `metadata`, `compatibility`) | Field allowlist |
| `spec-allowed-fields` | No unknown fields (only `name`, `description`, `license`, `allowed-tools`, `metadata`, `compatibility`, `user-invocable`, `disable-model-invocation`) | Field allowlist |
| `spec-name` | Lowercase, ≤64 chars, no leading/trailing `-`, no `--`, alphanumeric + hyphens only | Name constraints |
| `spec-dir-match` | Directory name matches skill `name` field | Directory = name |
| `spec-description` | Non-empty, ≤1024 characters | Description constraints |
| `spec-compatibility` | If present, ≤500 characters | Compatibility constraints |
| `spec-license` | Recommends adding `license` field | Optional but strongly recommended |
| `spec-version` | Recommends adding `metadata.version` | Optional but strongly recommended |
| `spec-security` | No XML angle brackets (`< >`) in frontmatter; name does not use reserved prefixes (`claude-`, `anthropic-`) | Security restrictions ([Anthropic guide](https://resources.anthropic.com/hubfs/The-Complete-Guide-to-Building-Skill-for-Claude.pdf), p31) |
| `copilot-user-invocable` | If present, must be boolean (`true`/`false`) | Copilot CLI extension |
| `copilot-disable-model-invocation` | If present, must be boolean (`true`/`false`) | Copilot CLI extension |
| `copilot-allowed-tools` | If present, must be non-empty comma-separated list | Copilot CLI extension |
| `copilot-reference-only-pattern` | Detects `user-invocable=false` + `disable-model-invocation=true` | Advisory (Copilot CLI) |

## Rule-Based Checks

Expand Down Expand Up @@ -196,6 +200,29 @@ Optional field documenting:
- Supported frameworks
- Prerequisites

### Copilot CLI Extension Fields

These fields are part of the Copilot skill frontmatter schema (not yet documented publicly; not part of the agentskills.io spec) and validated by Sensei when present:

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `allowed-tools` | string | — | Comma-separated list of auto-allowed tools while skill is active |
| `user-invocable` | boolean | `true` | Whether users can invoke via `/skill-name` |
| `disable-model-invocation` | boolean | `false` | Prevent the model from invoking this skill |

#### Reference-Only Pattern

When `user-invocable: false` AND `disable-model-invocation: true`, the skill becomes a **reference-only file** — loaded from disk but never invoked by user or model. Useful for shared context, configuration, or conventions.

```yaml
---
name: team-conventions
description: "Shared coding conventions for the team."
user-invocable: false
disable-model-invocation: true
---
```

## Scoring Algorithm

```python
Expand Down
2 changes: 2 additions & 0 deletions scripts/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

153 changes: 150 additions & 3 deletions scripts/src/tokens/commands/score.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ import {
checkDescriptionCompliance,
checkCompatibilityCompliance,
checkLicenseRecommendation,
checkVersionRecommendation
checkVersionRecommendation,
checkBooleanField,
checkAllowedToolsFormat,
checkReferenceOnlyPattern
} from './score.js';

describe('checkModuleCount', () => {
Expand Down Expand Up @@ -375,11 +378,155 @@ describe('checkAllowedFields', () => {
expect(result.message).toContain('author');
});

it('allows allowed-tools field', () => {
const fields = { name: 'x', description: 'y', 'allowed-tools': 'Bash Read' };
it('allows user-invocable field', () => {
const fields = { name: 'x', description: 'y', 'user-invocable': 'false' };
const result = checkAllowedFields(fields);
expect(result.status).toBe('ok');
});

it('allows disable-model-invocation field', () => {
const fields = { name: 'x', description: 'y', 'disable-model-invocation': 'true' };
const result = checkAllowedFields(fields);
expect(result.status).toBe('ok');
});

it('allows all Copilot CLI fields together', () => {
const fields = {
name: 'x', description: 'y', 'allowed-tools': 'Bash',
'user-invocable': 'true', 'disable-model-invocation': 'false'
};
const result = checkAllowedFields(fields);
expect(result.status).toBe('ok');
});
});

// ---------------------------------------------------------------------------
// Copilot CLI Extension Field Checks
// ---------------------------------------------------------------------------

describe('checkBooleanField', () => {
it('passes when field is not present', () => {
const result = checkBooleanField('user-invocable', undefined);
expect(result.status).toBe('ok');
expect(result.message).toContain('not present');
});

it('passes for "true"', () => {
const result = checkBooleanField('user-invocable', 'true');
expect(result.status).toBe('ok');
expect(result.message).toContain('true');
});

it('passes for "false"', () => {
const result = checkBooleanField('disable-model-invocation', 'false');
expect(result.status).toBe('ok');
expect(result.message).toContain('false');
});

it('passes for "yes"', () => {
const result = checkBooleanField('user-invocable', 'yes');
expect(result.status).toBe('ok');
});

it('passes for "no"', () => {
const result = checkBooleanField('user-invocable', 'no');
expect(result.status).toBe('ok');
});

it('warns on non-boolean value', () => {
const result = checkBooleanField('user-invocable', 'maybe');
expect(result.status).toBe('warning');
expect(result.message).toContain('boolean');
expect(result.message).toContain('maybe');
});

it('warns on numeric value', () => {
const result = checkBooleanField('disable-model-invocation', '1');
expect(result.status).toBe('warning');
});

it('handles quoted boolean values', () => {
const result = checkBooleanField('user-invocable', '"true"');
expect(result.status).toBe('ok');
});

it('uses correct check name based on field', () => {
const r1 = checkBooleanField('user-invocable', 'true');
expect(r1.name).toBe('copilot-user-invocable');

const r2 = checkBooleanField('disable-model-invocation', 'false');
expect(r2.name).toBe('copilot-disable-model-invocation');
});
});

describe('checkAllowedToolsFormat', () => {
it('passes when not present', () => {
const result = checkAllowedToolsFormat(undefined);
expect(result.status).toBe('ok');
expect(result.name).toBe('copilot-allowed-tools');
});

it('passes for valid comma-separated list', () => {
const result = checkAllowedToolsFormat('Bash, Read, Write');
expect(result.status).toBe('ok');
expect(result.message).toContain('Bash, Read, Write');
});

it('passes for single tool', () => {
const result = checkAllowedToolsFormat('Bash');
expect(result.status).toBe('ok');
});

it('warns on empty string', () => {
const result = checkAllowedToolsFormat('');
expect(result.status).toBe('warning');
expect(result.message).toContain('empty');
});

it('warns on whitespace-only', () => {
const result = checkAllowedToolsFormat(' ');
expect(result.status).toBe('warning');
});
});

describe('checkReferenceOnlyPattern', () => {
it('returns ok when both fields absent (defaults)', () => {
const result = checkReferenceOnlyPattern(undefined, undefined);
expect(result.status).toBe('ok');
expect(result.name).toBe('copilot-reference-only-pattern');
expect(result.message).toContain('defaults apply');
});

it('detects reference-only pattern', () => {
const result = checkReferenceOnlyPattern('false', 'true');
expect(result.status).toBe('optimal');
expect(result.message).toContain('Reference-only');
});

it('detects reference-only with yes/no values', () => {
const result = checkReferenceOnlyPattern('no', 'yes');
expect(result.status).toBe('optimal');
expect(result.message).toContain('Reference-only');
});

it('reports user-invocable=false only', () => {
const result = checkReferenceOnlyPattern('false', undefined);
expect(result.status).toBe('ok');
expect(result.message).toContain('not user-invocable');
expect(result.message).toContain('model-invoked');
});

it('reports disable-model-invocation=true only', () => {
const result = checkReferenceOnlyPattern(undefined, 'true');
expect(result.status).toBe('ok');
expect(result.message).toContain('user-invocable only');
});

it('returns ok for standard defaults (both true/false)', () => {
const result = checkReferenceOnlyPattern('true', 'false');
expect(result.status).toBe('ok');
expect(result.message).toContain('Standard invocable');
});
});

describe('checkNameCompliance', () => {
Expand Down
Loading
Loading