diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index bac60a769..566d41f78 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,6 +6,7 @@ "plugins": [ { "name": "specorator", + "category": "development", "source": { "source": "git-subdir", "url": "https://github.com/Luis85/agentic-workflow.git", diff --git a/claude-plugin/specorator/hooks/hooks.json b/claude-plugin/specorator/hooks/hooks.json new file mode 100644 index 000000000..758da005c --- /dev/null +++ b/claude-plugin/specorator/hooks/hooks.json @@ -0,0 +1,26 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "test -f .claude/memory/MEMORY.md && echo '[memory] Read .claude/memory/MEMORY.md before non-trivial work — workflow rules + project state are indexed there.' || true" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 -c 'import sys,json,re,subprocess; d=json.load(sys.stdin); cmd=d.get(\"tool_input\",{}).get(\"command\",\"\"); dq=chr(34); sq=chr(39); strip_q=lambda s:re.sub(f\"{dq}[^{dq}]*{dq}|{sq}[^{sq}]*{sq}\",\"\",s); is_gc=lambda s:bool(re.search(r\"(?:^|[|;&({]|\\n|\\bthen\\b)\\s*(?:(?:[A-Za-z_]\\w*=[^\\s]*|env|sudo|time|nice|nohup)\\s+)*git\\b[^|&;]*\\scommit\\b\",strip_q(s))); parts=[cmd]+[m.group(1) or m.group(2) for m in re.finditer(r\"\\b(?:bash|sh)\\b[^|&;]*-[a-zA-Z]*c\\s+(?:\"+dq+r\"([^\"+dq+r\"]+)\"+dq+r\"|\"+sq+r\"([^\"+sq+r\"]+)\"+sq+r\")\",cmd)]; any(is_gc(p) for p in parts) or sys.exit(0); b=subprocess.run([\"git\",\"symbolic-ref\",\"--short\",\"HEAD\"],capture_output=True,text=True).stdout.strip(); b in(\"main\",\"develop\") and (print(\"[branch-guard] Commit on\",repr(b),\"blocked. Use a topic branch.\",file=sys.stderr) or sys.exit(2))'" + } + ] + } + ] + } +} diff --git a/claude-plugin/specorator/settings.json b/claude-plugin/specorator/settings.json new file mode 100644 index 000000000..6cb8fcdb5 --- /dev/null +++ b/claude-plugin/specorator/settings.json @@ -0,0 +1,3 @@ +{ + "agent": "orchestrator" +} diff --git a/scripts/build-claude-plugin.ts b/scripts/build-claude-plugin.ts index 35edcf22a..43367b147 100644 --- a/scripts/build-claude-plugin.ts +++ b/scripts/build-claude-plugin.ts @@ -75,6 +75,7 @@ function buildExpectedManifest(): string { failIfErrors([`package.json#version missing or empty (${toPosix(path.relative(repoRoot, pkgPath))})`], "build:claude-plugin"); } const manifest = { + $schema: "https://json.schemastore.org/claude-code-plugin-manifest.json", name: "specorator", version: pkg.version as string, description: "Spec-driven agentic software development workflow for Claude Code.", diff --git a/scripts/check-claude-plugin.ts b/scripts/check-claude-plugin.ts index 47903c0fc..060532014 100644 --- a/scripts/check-claude-plugin.ts +++ b/scripts/check-claude-plugin.ts @@ -2,11 +2,12 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; -import { failIfErrors, parseSimpleYaml, readText, repoRoot } from "./lib/repo.js"; +import { failIfErrors, parseSimpleYaml, readText, repoRoot, walkFiles } from "./lib/repo.js"; const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); type PluginManifest = { + $schema?: unknown; name?: unknown; version?: unknown; description?: unknown; @@ -34,6 +35,9 @@ const errors: string[] = []; // Structural checks against canonical/committed sources always run. checkMarketplace(); checkPluginReadme(); +checkPluginHooks(); +checkPluginSettings(); +warnMissingSkillDescriptions(); // Generated-output checks (per ADR-0043) gate as a single all-or-nothing // group. The bundle is gitignored on develop/main and produced by the CI @@ -109,6 +113,7 @@ function checkPluginManifest(): void { const manifest = readJson(manifestPath, "plugin manifest"); if (!manifest) return; + requireString(manifest.$schema, "claude-plugin/specorator/.claude-plugin/plugin.json $schema"); requireString(manifest.name, "claude-plugin/specorator/.claude-plugin/plugin.json name"); requireString(manifest.version, "claude-plugin/specorator/.claude-plugin/plugin.json version"); requireString(manifest.description, "claude-plugin/specorator/.claude-plugin/plugin.json description"); @@ -138,6 +143,10 @@ function checkMarketplace(): void { return; } + if (typeof specorator["category"] !== "string" || !(specorator["category"] as string).trim()) { + errors.push(".claude-plugin/marketplace.json specorator entry must have a non-empty category string"); + } + // ADR-0043 — source must be a git-subdir object pinning ref to dist/claude-plugin. const source = specorator["source"]; if (!source || typeof source !== "object" || Array.isArray(source)) { @@ -231,3 +240,79 @@ function countFiles(root: string): number { } return count; } + +function checkPluginHooks(): void { + const hooksPath = path.join(pluginRoot, "hooks", "hooks.json"); + const hooksJson = readJson<{ hooks?: Record }>(hooksPath, "claude-plugin/specorator/hooks/hooks.json"); + if (!hooksJson) return; + if (!hooksJson.hooks?.["PreToolUse"]) { + errors.push("claude-plugin/specorator/hooks/hooks.json must define PreToolUse (branch guard requires it)"); + } + + // Extract command strings only from Bash-matcher PreToolUse hooks to + // avoid false positives from non-Bash hooks that happen to mention + // sys.exit(2) in a non-command field. + const preToolUseHooks = hooksJson.hooks?.["PreToolUse"]; + const bashCommands: string[] = Array.isArray(preToolUseHooks) + ? preToolUseHooks + .filter((h: unknown): h is Record => + !!h && typeof h === "object" && !Array.isArray(h) && + (h as Record)["matcher"] === "Bash" + ) + .flatMap((h) => { + const inner = h["hooks"]; + return Array.isArray(inner) + ? inner + .filter((ih: unknown): ih is Record => + !!ih && typeof ih === "object" + ) + .map((ih) => (typeof ih["command"] === "string" ? ih["command"] : "")) + : []; + }) + : []; + + // PreToolUse must use sys.exit(2) for blocking semantics — catches absent, + // empty, and wrong-exit-code commands in one check. + if (!bashCommands.some((cmd) => cmd.includes("sys.exit(2)"))) { + errors.push("hooks/hooks.json: PreToolUse branch guard must use sys.exit(2) for blocking semantics"); + } + + // Verify no swallowed-failure suffix: `|| exit N` after the hook command + // would neutralise sys.exit(2) blocking semantics and allow commits on + // protected branches to pass through silently. + if (bashCommands.some((cmd) => /\|\|\s*exit\s+\d/.test(cmd))) { + errors.push( + "claude-plugin/specorator/hooks/hooks.json PreToolUse branch guard must not end with || exit — " + + "this swallows the blocking exit code and neutralises branch protection" + ); + } +} + +function checkPluginSettings(): void { + const settingsPath = path.join(pluginRoot, "settings.json"); + const settings = readJson>(settingsPath, "claude-plugin/specorator/settings.json"); + if (!settings) return; + if (typeof settings["agent"] !== "string" || !(settings["agent"] as string).trim()) { + errors.push("claude-plugin/specorator/settings.json must have a non-empty agent string field"); + } +} + +function warnMissingSkillDescriptions(): void { + const skillsDir = path.join(repoRoot, ".claude", "skills"); + if (!fs.existsSync(skillsDir)) return; + if (!fs.statSync(skillsDir).isDirectory()) return; + const skillsDirRel = path.relative(repoRoot, skillsDir); + for (const skillPath of walkFiles(skillsDirRel, (f) => path.basename(f) === "SKILL.md")) { + const text = readText(skillPath); + if (!text.startsWith("---\n")) { + console.warn(`[warn] check:claude-plugin: ${path.relative(repoRoot, skillPath)} missing frontmatter (no description:)`); + continue; + } + const endFm = text.indexOf("\n---\n", 4); + if (endFm === -1) continue; + const fm = text.slice(4, endFm); + if (!fm.includes("description:")) { + console.warn(`[warn] check:claude-plugin: ${path.relative(repoRoot, skillPath)} missing description: in frontmatter`); + } + } +} diff --git a/tests/scripts/claude-plugin.test.ts b/tests/scripts/claude-plugin.test.ts index c6942deba..c8d1aec90 100644 --- a/tests/scripts/claude-plugin.test.ts +++ b/tests/scripts/claude-plugin.test.ts @@ -145,6 +145,8 @@ test("check:claude-plugin passes on a clean checkout with no generated bundle (A "claude-plugin/specorator/README.md", ["---", 'title: "Plugin"', 'folder: "claude-plugin/specorator"', 'description: "Plugin package."', "entry_point: true", "---", "", "# Plugin"].join("\n"), ); + write(root, "claude-plugin/specorator/hooks/hooks.json", JSON.stringify(hooksFixture())); + write(root, "claude-plugin/specorator/settings.json", JSON.stringify({ agent: "orchestrator" })); const result = runScript(checkScript, root); expect(result.status).toBe(0); @@ -230,6 +232,8 @@ function seedCheckFixture(root: string): void { "claude-plugin/specorator/README.md", ["---", 'title: "Plugin"', 'folder: "claude-plugin/specorator"', 'description: "Plugin package."', "entry_point: true", "---", "", "# Plugin"].join("\n"), ); + write(root, "claude-plugin/specorator/hooks/hooks.json", JSON.stringify(hooksFixture())); + write(root, "claude-plugin/specorator/settings.json", JSON.stringify({ agent: "orchestrator" })); // Placeholder plugin.json so build:claude-plugin's compareManifest sees the // generated tree's parent dir already exists. write(root, "claude-plugin/specorator/.claude-plugin/plugin.json", "{}\n"); @@ -241,6 +245,7 @@ function marketplaceFixture() { plugins: [ { name: "specorator", + category: "development", source: { source: "git-subdir", url: "https://github.com/Luis85/agentic-workflow.git", @@ -252,6 +257,25 @@ function marketplaceFixture() { }; } +function hooksFixture() { + return { + hooks: { + SessionStart: [ + { + matcher: "*", + hooks: [{ type: "command", command: "echo ok" }], + }, + ], + PreToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "python3 -c 'import sys; sys.exit(2)'" }], + }, + ], + }, + }; +} + function makeFixtureRoot(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "claude-plugin-fixture-")); }