Copilot plugin focused on GitHub customization asset authoring workflows.

Installable plugin for creating customization assets that load in both GitHub Copilot and Claude Code from a single copy of every file.
agents/spec-builder.agent.mdskills/create-agent/SKILL.mdskills/create-instruction/SKILL.mdskills/create-plugin/SKILL.mdskills/create-skill/SKILL.mdskills/create-workflow/SKILL.mdresources/agent-naming.mdresources/agent-spec-workflow.mdresources/create-agent.mdresources/create-instruction.mdresources/create-plugin.mdresources/create-skill.mdresources/create-canvas.mdresources/create-workflow.mdresources/spec-conciseness.mdresources/quick-reference.mdhooks.json (session-start authoring quality guardrail prompt), with its Claude twin in hooks/hooks/budget-status.ts — Claude only, see Body budget statusIn Claude Code, after every Write or Edit the status line shows the edited asset's body lines against its budget from resources/spec-conciseness.md:
| Asset | Budget |
|---|---|
SKILL.md | 40 |
*.agent.md | 80 |
rules/*.md, .agents/rules/*.md, a resources/*.md contract (name + description) | 60 |
Body lines are the non-blank lines after the frontmatter, as tools/check-assets.mjs counts them. SKILL.md 37/40 is plain below 90% of the budget, 🟡 within 10% of it, 🔴 over it, and (exempt) when the file states why it exceeds it ("Over the 60-line budget by design: ..."). A toast fires once per file per session when an edit first takes it over. Editing any other file clears the status. It matches by filename, so it works in any repository. Tests:
claude plugin validate plugins/spec-builder
claude plugin test plugins/spec-builder
spec-builder agent owns the full flow: scope, plan, build, verify, and report.create-* skills and matching authoring instructions.Every asset this plugin produces is authored once and read by both GitHub Copilot and Claude Code. Both manifests and both hook files are hand-authored — nothing is generated — and a change to one host's file is a change owed to the other. Run the checker before committing; CI fails on drift:
node tools/check-assets.mjs
Canvas extensions are the one Copilot-only asset type. Full rules: Crosscutting Concepts.
copilot plugin install JSdotNet/ai-plugins:plugins/spec-builder
copilot plugin list
copilot plugin install JSdotNet/ai-plugins:plugins/spec-builder
copilot plugin uninstall spec-builder
.agent.md, .instructions.md, and SKILL.md files.create-prompt skill and matching resources/create-prompt.md to cover .prompt.md assets.resources/ referenced by the create-canvas instructions.create-spec skill for structured specification documents that drive multi-step agent workflows.plugin.json schema validation — add a validate-plugin skill that checks manifest completeness and path integrity before install.hooks.json and .mcp.json to support lifecycle automation and MCP server wiring.resources/ folder with reusable checklists, frontmatter templates, and example assets for bootstrapping new customization work.create-plugin to include marketplace.json composition and publishing readiness checks.hooks/budget-status.ts 57 lines1// Claude-only function hooks: after every Write or Edit, pin the edited asset's body-line
2// count against its budget in the status line, and toast once when a file first crosses it.
3// The budgets and the exemption are resources/spec-conciseness.md's; budget.ts holds the logic.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { SpecBuilderToasted } from '../types'
8import { classify, isBudgeted, label, measure } from './budget'
9
10const toasted = atom({ plugin: 'spec-builder', key: 'toasted' } as const, [] as SpecBuilderToasted)
11
12async function readText($: EngineInterface, path: string): Promise<string | null> {
13 try {
14 const text = await $.fs.read(path)
15 return typeof text === 'string' ? text : null
16 } catch {
17 return null
18 }
19}
20
21export const register: Register = on => {
22 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
23 const path = e.file_path
24 const asset = classify(path)
25 if (asset === null) {
26 const ran = await next(e)
27 $.ui.status(undefined)
28 return ran
29 }
30
31 const before = await readText($, path)
32 const ran = await next(e)
33 if (ran.deny !== undefined || ran.isError === true) return ran
34
35 const after = await readText($, path)
36 if (after === null || !isBudgeted(asset, path, after)) {
37 $.ui.status(undefined)
38 return ran
39 }
40
41 const now = measure(asset.budget, after)
42 $.ui.status(label(path, now))
43
44 const wasOver = before !== null && measure(asset.budget, before).level === 'over'
45 if (now.level === 'over' && !now.isExempt && !wasOver) {
46 const key = path.replace(/\\/g, '/')
47 if (!(await read($, toasted)).includes(key)) {
48 await update($, toasted, list => [...(list ?? []), key])
49 $.ui.toast(
50 `${label(path, now)}: over its ${now.budget}-line body budget. Trim it, move reference behind a pointer, or state why in the file.`,
51 )
52 }
53 }
54 return ran
55 })
56}
57hooks/budget.ts 63 lines1// Body budgets from resources/spec-conciseness.md: what counts as a budgeted asset, how its
2// body is counted, and when it states why it exceeds the budget. Pure, so the hooks module
3// and its tests share one copy. Counting matches tools/check-assets.mjs: non-blank lines
4// after the frontmatter.
5
6export type Asset = { kind: 'SKILL.md' | 'agent' | 'contract'; budget: number }
7
8export type Measure = {
9 lines: number
10 budget: number
11 isExempt: boolean
12 level: 'ok' | 'near' | 'over'
13}
14
15const FRONTMATTER = /^?---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
16
17// "Over the 60-line budget by design: ...", "exceeds its budget because ..."
18const EXEMPTION = /\b(over|exceeds?|past|beyond)\s+(the|its|this)\s+(\d+-line\s+)?(body\s+)?budget\b/i
19
20export function classify(filePath: string): Asset | null {
21 const parts = filePath.replace(/\\/g, '/').split('/')
22 const base = parts.at(-1) ?? ''
23 const parent = parts.at(-2) ?? ''
24 if (base === 'SKILL.md') return { kind: 'SKILL.md', budget: 40 }
25 if (base.endsWith('.agent.md')) return { kind: 'agent', budget: 80 }
26 if (base.endsWith('.md') && base !== 'README.md' && (parent === 'rules' || parent === 'resources')) {
27 return { kind: 'contract', budget: 60 }
28 }
29 return null
30}
31
32function split(text: string): { fm: string | null; body: string } {
33 const m = FRONTMATTER.exec(text)
34 return m ? { fm: m[1] ?? '', body: m[2] ?? '' } : { fm: null, body: text }
35}
36
37// A resources/ file without name and description is a template or prompt fragment, not a
38// contract, and carries no budget.
39export function isBudgeted(asset: Asset, filePath: string, text: string): boolean {
40 if (asset.kind !== 'contract') return true
41 const parent = filePath.replace(/\\/g, '/').split('/').at(-2)
42 if (parent !== 'resources') return true
43 const { fm } = split(text)
44 return fm !== null && /^name:/m.test(fm) && /^description:/m.test(fm)
45}
46
47export function measure(budget: number, text: string): Measure {
48 const { body } = split(text)
49 const lines = body.split(/\r?\n/).filter(l => l.trim() !== '').length
50 const isExempt = lines > budget && EXEMPTION.test(body)
51 const level = lines > budget ? 'over' : lines >= budget * 0.9 ? 'near' : 'ok'
52 return { lines, budget, isExempt, level }
53}
54
55export function label(filePath: string, m: Measure): string {
56 const base = filePath.replace(/\\/g, '/').split('/').at(-1) ?? filePath
57 const text = `${base} ${m.lines}/${m.budget}`
58 if (m.isExempt) return `${text} (exempt)`
59 if (m.level === 'over') return `🔴 ${text}`
60 if (m.level === 'near') return `🟡 ${text}`
61 return text
62}
63types/index.d.ts 10 lines1// The spec-builder hooks module's session state: the budgeted files it already toasted
2// about, so a reload or a second crossing in the same session stays quiet.
3export type SpecBuilderToasted = string[]
4
5declare module 'claude-code' {
6 interface PluginState {
7 'spec-builder': { toasted: SpecBuilderToasted }
8 }
9}
10