SLOPSHOPPER

spec-builder

Copilot plugin focused on GitHub customization asset authoring workflows.

newguardtoaststatus
v0.6.0MITupdated 2026-10-07JSdotNet/ai-plugins/plugins/spec-builder
A shopper browsing a rack in a slop shop
README

spec-builder

Installable plugin for creating customization assets that load in both GitHub Copilot and Claude Code from a single copy of every file.

Includes

  • Agent:
  • agents/spec-builder.agent.md
  • Skills:
  • skills/create-agent/SKILL.md
  • skills/create-instruction/SKILL.md
  • skills/create-plugin/SKILL.md
  • skills/create-skill/SKILL.md
  • skills/create-workflow/SKILL.md
  • Instructions:
  • resources/agent-naming.md
  • resources/agent-spec-workflow.md
  • resources/create-agent.md
  • resources/create-instruction.md
  • resources/create-plugin.md
  • resources/create-skill.md
  • resources/create-canvas.md
  • resources/create-workflow.md
  • resources/spec-conciseness.md
  • Resources:
  • resources/quick-reference.md
  • Hooks:
  • hooks.json (session-start authoring quality guardrail prompt), with its Claude twin in hooks/
  • hooks/budget-status.ts — Claude only, see Body budget status

Body budget status

In 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:

AssetBudget
SKILL.md40
*.agent.md80
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

Scope

  • This plugin focuses on creating and refining GitHub customization assets: agents, instructions, plugins, skills, canvas extensions, and GitHub Actions workflow files.
  • A single spec-builder agent owns the full flow: scope, plan, build, verify, and report.
  • Asset-specific rules live in the create-* skills and matching authoring instructions.
  • It does not provide runtime application code implementation.
  • It is self-contained and does not require assets from an external source repository.

Dual-Host Authoring

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.

Install

copilot plugin install JSdotNet/ai-plugins:plugins/spec-builder
copilot plugin list

Reinstall After Changes

copilot plugin install JSdotNet/ai-plugins:plugins/spec-builder

Uninstall

copilot plugin uninstall spec-builder

Resources

Future Upgrades

  • Review create naming
  • Prompt authoring skill — add a create-prompt skill and matching resources/create-prompt.md to cover .prompt.md assets.
  • Multi-action canvas templates — add reusable canvas renderer templates (static-file server, Vite dev server wiring) to resources/ referenced by the create-canvas instructions.
  • Spec authoring skill — add a 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 and MCP authoring — add skills for hooks.json and .mcp.json to support lifecycle automation and MCP server wiring.
  • Resource templates folder — add a resources/ folder with reusable checklists, frontmatter templates, and example assets for bootstrapping new customization work.
  • Marketplace publishing workflow — extend create-plugin to include marketplace.json composition and publishing readiness checks.
Source 3 files
hooks/budget-status.ts 57 lines
1// 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}
57
hooks/budget.ts 63 lines
1// 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}
63
types/index.d.ts 10 lines
1// 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