From 679a919706a3d1b996e746b59fa9f191cf408928 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 21:34:00 +0000 Subject: [PATCH 01/18] feat(plugin): add hooks, settings, extend validator (#447 #448 #451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create claude-plugin/specorator/hooks/hooks.json with SessionStart and PreToolUse hooks (branch guard exits 2 for blocking — not 1) - Create claude-plugin/specorator/settings.json with agent: orchestrator - Add $schema to buildExpectedManifest() in build-claude-plugin.ts - Add category: development to .claude-plugin/marketplace.json - Extend check-claude-plugin.ts with checkPluginHooks(), checkPluginSettings(), $schema validation, category check, and warnMissingSkillDescriptions() - Update test fixtures to include new required committed-source files https://claude.ai/code/session_01PqUQc4Vg5vMB4eDpg9MmeM --- .claude-plugin/marketplace.json | 1 + claude-plugin/specorator/hooks/hooks.json | 24 +++++++++++ claude-plugin/specorator/settings.json | 3 ++ scripts/build-claude-plugin.ts | 1 + scripts/check-claude-plugin.ts | 50 ++++++++++++++++++++++- tests/scripts/claude-plugin.test.ts | 22 ++++++++++ 6 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 claude-plugin/specorator/hooks/hooks.json create mode 100644 claude-plugin/specorator/settings.json 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..5e86f31da --- /dev/null +++ b/claude-plugin/specorator/hooks/hooks.json @@ -0,0 +1,24 @@ +{ + "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); cleaned=re.sub(dq+r\"[^\"+dq+r\"]*\"+dq+r\"|\"+sq+r\"[^\"+sq+r\"]*\"+sq,\"\",cmd); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cleaned) 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.\") or sys.exit(2))' 2>/dev/null || exit 0" + } + ] + } + ] +} 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..658355bc8 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.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 a388740e3..ee99bdb26 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,8 @@ const errors: string[] = []; // Structural checks against canonical/committed sources always run. checkMarketplace(); checkPluginReadme(); +checkPluginHooks(); +checkPluginSettings(); // 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 @@ -63,6 +66,7 @@ if (presentCount > 0 && presentCount < generatedPaths.length) { // hand-edited bundles pass even though they will diverge from what the CI // publish workflow rebuilds (Codex P2 on PR #478). Run the byte-level // comparison here only when the full bundle is present. + warnMissingSkillDescriptions(); checkBundleDrift(); } @@ -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)) { @@ -230,3 +239,42 @@ function countFiles(root: string): number { } return count; } + +function checkPluginHooks(): void { + const hooksPath = path.join(pluginRoot, "hooks", "hooks.json"); + const hooks = readJson>(hooksPath, "claude-plugin/specorator/hooks/hooks.json"); + if (!hooks) return; + if (!hooks["SessionStart"] && !hooks["PreToolUse"]) { + errors.push("claude-plugin/specorator/hooks/hooks.json must define at least SessionStart or PreToolUse"); + } + // Verify branch guard uses exit 2 (blocking), not exit 1 + const preToolUseStr = JSON.stringify(hooks["PreToolUse"] ?? ""); + if (preToolUseStr.includes("sys.exit") && !preToolUseStr.includes("sys.exit(2)")) { + errors.push("claude-plugin/specorator/hooks/hooks.json PreToolUse branch guard must exit 2, not exit 1"); + } +} + +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(pluginRoot, "skills"); + if (!fs.existsSync(skillsDir)) return; + const skillsDirRel = path.relative(repoRoot, skillsDir); + for (const skillPath of walkFiles(skillsDirRel, (f) => f.endsWith(".md"))) { + const text = readText(skillPath); + if (!text.startsWith("---\n")) 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..f12ac5e32 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,23 @@ function marketplaceFixture() { }; } +function hooksFixture() { + return { + 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-")); } From 0ffbb3ff576ccdc247acfb9c260fff666af4dc8f Mon Sep 17 00:00:00 2001 From: Luis Mendez <3923861+Luis85@users.noreply.github.com> Date: Thu, 14 May 2026 02:11:32 +0200 Subject: [PATCH 02/18] fix(plugin): remove || exit 0 from branch guard; tighten validator --- claude-plugin/specorator/hooks/hooks.json | 2 +- scripts/check-claude-plugin.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/claude-plugin/specorator/hooks/hooks.json b/claude-plugin/specorator/hooks/hooks.json index 5e86f31da..50620ead4 100644 --- a/claude-plugin/specorator/hooks/hooks.json +++ b/claude-plugin/specorator/hooks/hooks.json @@ -16,7 +16,7 @@ "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); cleaned=re.sub(dq+r\"[^\"+dq+r\"]*\"+dq+r\"|\"+sq+r\"[^\"+sq+r\"]*\"+sq,\"\",cmd); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cleaned) 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.\") or sys.exit(2))' 2>/dev/null || exit 0" + "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); cleaned=re.sub(dq+r\"[^\"+dq+r\"]*\"+dq+r\"|\"+sq+r\"[^\"+sq+r\"]*\"+sq,\"\",cmd); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cleaned) 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.\") or sys.exit(2))' 2>/dev/null" } ] } diff --git a/scripts/check-claude-plugin.ts b/scripts/check-claude-plugin.ts index ee99bdb26..f36f18bc5 100644 --- a/scripts/check-claude-plugin.ts +++ b/scripts/check-claude-plugin.ts @@ -252,6 +252,15 @@ function checkPluginHooks(): void { if (preToolUseStr.includes("sys.exit") && !preToolUseStr.includes("sys.exit(2)")) { errors.push("claude-plugin/specorator/hooks/hooks.json PreToolUse branch guard must exit 2, not exit 1"); } + // 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 (/\|\|\s*exit\s+\d/.test(preToolUseStr)) { + 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 { From c0f4f93a1650f895834bb0dc2499dfe6a4f46eea Mon Sep 17 00:00:00 2001 From: Luis Mendez <3923861+Luis85@users.noreply.github.com> Date: Thu, 14 May 2026 02:21:00 +0200 Subject: [PATCH 03/18] fix(plugin): validate PreToolUse hook independently; use stderr for block message --- claude-plugin/specorator/hooks/hooks.json | 2 +- scripts/check-claude-plugin.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/claude-plugin/specorator/hooks/hooks.json b/claude-plugin/specorator/hooks/hooks.json index 50620ead4..487543d89 100644 --- a/claude-plugin/specorator/hooks/hooks.json +++ b/claude-plugin/specorator/hooks/hooks.json @@ -16,7 +16,7 @@ "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); cleaned=re.sub(dq+r\"[^\"+dq+r\"]*\"+dq+r\"|\"+sq+r\"[^\"+sq+r\"]*\"+sq,\"\",cmd); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cleaned) 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.\") or sys.exit(2))' 2>/dev/null" + "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); cleaned=re.sub(dq+r\"[^\"+dq+r\"]*\"+dq+r\"|\"+sq+r\"[^\"+sq+r\"]*\"+sq,\"\",cmd); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cleaned) 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/scripts/check-claude-plugin.ts b/scripts/check-claude-plugin.ts index f36f18bc5..3d275c5cc 100644 --- a/scripts/check-claude-plugin.ts +++ b/scripts/check-claude-plugin.ts @@ -244,8 +244,8 @@ function checkPluginHooks(): void { const hooksPath = path.join(pluginRoot, "hooks", "hooks.json"); const hooks = readJson>(hooksPath, "claude-plugin/specorator/hooks/hooks.json"); if (!hooks) return; - if (!hooks["SessionStart"] && !hooks["PreToolUse"]) { - errors.push("claude-plugin/specorator/hooks/hooks.json must define at least SessionStart or PreToolUse"); + if (!hooks["PreToolUse"]) { + errors.push("claude-plugin/specorator/hooks/hooks.json must define PreToolUse (branch guard requires it)"); } // Verify branch guard uses exit 2 (blocking), not exit 1 const preToolUseStr = JSON.stringify(hooks["PreToolUse"] ?? ""); From 5ce26a34fe05e4a166f6e724b4b5c5a0f20524d6 Mon Sep 17 00:00:00 2001 From: Luis Mendez <3923861+Luis85@users.noreply.github.com> Date: Thu, 14 May 2026 02:33:35 +0200 Subject: [PATCH 04/18] =?UTF-8?q?fix(plugin):=20require=20sys.exit(2)=20in?= =?UTF-8?q?=20PreToolUse=20hook=20=E2=80=94=20catch=20empty/missing=20guar?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/check-claude-plugin.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/check-claude-plugin.ts b/scripts/check-claude-plugin.ts index 3d275c5cc..81ca288c3 100644 --- a/scripts/check-claude-plugin.ts +++ b/scripts/check-claude-plugin.ts @@ -247,10 +247,11 @@ function checkPluginHooks(): void { if (!hooks["PreToolUse"]) { errors.push("claude-plugin/specorator/hooks/hooks.json must define PreToolUse (branch guard requires it)"); } - // Verify branch guard uses exit 2 (blocking), not exit 1 const preToolUseStr = JSON.stringify(hooks["PreToolUse"] ?? ""); - if (preToolUseStr.includes("sys.exit") && !preToolUseStr.includes("sys.exit(2)")) { - errors.push("claude-plugin/specorator/hooks/hooks.json PreToolUse branch guard must exit 2, not exit 1"); + // PreToolUse must use sys.exit(2) for blocking semantics — catches absent, + // empty, and wrong-exit-code commands in one check. + if (!preToolUseStr.includes("sys.exit(2)")) { + errors.push("claude-plugin/specorator/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 From ada954bc970fd9945631e1c905c067cd65b8a01a Mon Sep 17 00:00:00 2001 From: Luis Date: Thu, 14 May 2026 01:05:20 +0000 Subject: [PATCH 05/18] fix(plugin): nest hooks.json under hooks key, fix validator path --- claude-plugin/specorator/hooks/hooks.json | 46 ++++++++++++----------- scripts/check-claude-plugin.ts | 8 ++-- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/claude-plugin/specorator/hooks/hooks.json b/claude-plugin/specorator/hooks/hooks.json index 487543d89..db38b21ed 100644 --- a/claude-plugin/specorator/hooks/hooks.json +++ b/claude-plugin/specorator/hooks/hooks.json @@ -1,24 +1,26 @@ { - "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); cleaned=re.sub(dq+r\"[^\"+dq+r\"]*\"+dq+r\"|\"+sq+r\"[^\"+sq+r\"]*\"+sq,\"\",cmd); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cleaned) 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))'" - } - ] - } - ] + "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); cleaned=re.sub(dq+r\"[^\"+dq+r\"]*\"+dq+r\"|\"+sq+r\"[^\"+sq+r\"]*\"+sq,\"\",cmd); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cleaned) 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/scripts/check-claude-plugin.ts b/scripts/check-claude-plugin.ts index 81ca288c3..d9a1afc1d 100644 --- a/scripts/check-claude-plugin.ts +++ b/scripts/check-claude-plugin.ts @@ -242,12 +242,12 @@ function countFiles(root: string): number { function checkPluginHooks(): void { const hooksPath = path.join(pluginRoot, "hooks", "hooks.json"); - const hooks = readJson>(hooksPath, "claude-plugin/specorator/hooks/hooks.json"); - if (!hooks) return; - if (!hooks["PreToolUse"]) { + 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)"); } - const preToolUseStr = JSON.stringify(hooks["PreToolUse"] ?? ""); + const preToolUseStr = JSON.stringify(hooksJson.hooks?.["PreToolUse"] ?? ""); // PreToolUse must use sys.exit(2) for blocking semantics — catches absent, // empty, and wrong-exit-code commands in one check. if (!preToolUseStr.includes("sys.exit(2)")) { From 482c22c00197d445a0ff413ff9483cb2f853ad1b Mon Sep 17 00:00:00 2001 From: Luis Mendez <3923861+Luis85@users.noreply.github.com> Date: Thu, 14 May 2026 03:15:02 +0200 Subject: [PATCH 06/18] fix(tests): wrap hooksFixture under hooks key to match updated schema --- tests/scripts/claude-plugin.test.ts | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/tests/scripts/claude-plugin.test.ts b/tests/scripts/claude-plugin.test.ts index f12ac5e32..c8d1aec90 100644 --- a/tests/scripts/claude-plugin.test.ts +++ b/tests/scripts/claude-plugin.test.ts @@ -259,18 +259,20 @@ function marketplaceFixture() { function hooksFixture() { return { - SessionStart: [ - { - matcher: "*", - hooks: [{ type: "command", command: "echo ok" }], - }, - ], - PreToolUse: [ - { - matcher: "Bash", - hooks: [{ type: "command", command: "python3 -c 'import sys; sys.exit(2)'" }], - }, - ], + hooks: { + SessionStart: [ + { + matcher: "*", + hooks: [{ type: "command", command: "echo ok" }], + }, + ], + PreToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "python3 -c 'import sys; sys.exit(2)'" }], + }, + ], + }, }; } From be4ee51efac39e86da742c7f40aa8fd199b5be92 Mon Sep 17 00:00:00 2001 From: Luis Mendez <3923861+Luis85@users.noreply.github.com> Date: Thu, 14 May 2026 03:47:04 +0200 Subject: [PATCH 07/18] fix(plugin): remove quote-stripping bypass from branch-guard hook The previous implementation stripped quoted strings before scanning for `git commit`, allowing `bash -c "git commit"` to bypass the guard. Use the raw command string so all commit invocations are detected. Addresses Codex P1 review thread on PR #507. --- claude-plugin/specorator/hooks/hooks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/claude-plugin/specorator/hooks/hooks.json b/claude-plugin/specorator/hooks/hooks.json index db38b21ed..88e2c0157 100644 --- a/claude-plugin/specorator/hooks/hooks.json +++ b/claude-plugin/specorator/hooks/hooks.json @@ -17,7 +17,7 @@ "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); cleaned=re.sub(dq+r\"[^\"+dq+r\"]*\"+dq+r\"|\"+sq+r\"[^\"+sq+r\"]*\"+sq,\"\",cmd); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cleaned) 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))'" + "command": "python3 -c 'import sys,json,re,subprocess; d=json.load(sys.stdin); cmd=d.get(\"tool_input\",{}).get(\"command\",\"\"); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cmd) 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))'" } ] } From 2c3d4d19a06f04100db7bd17ca1996a02b5e8208 Mon Sep 17 00:00:00 2001 From: Luis Mendez <3923861+Luis85@users.noreply.github.com> Date: Thu, 14 May 2026 04:28:54 +0200 Subject: [PATCH 08/18] fix(plugin): correct $schema URL in buildExpectedManifest The SchemaStore slug for Claude Code plugins is `claude-code-plugin-manifest`, not `claude-code-plugin`. Using the wrong slug returns 404 and falls back to no-schema validation, defeating the manifest validation check. Fixes Codex P2 thread on PR #507. --- scripts/build-claude-plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build-claude-plugin.ts b/scripts/build-claude-plugin.ts index 658355bc8..43367b147 100644 --- a/scripts/build-claude-plugin.ts +++ b/scripts/build-claude-plugin.ts @@ -75,7 +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.json", + $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.", From 4b20e94f1c4e6a2dcf004984f158f4926fcead02 Mon Sep 17 00:00:00 2001 From: Luis Mendez <3923861+Luis85@users.noreply.github.com> Date: Thu, 14 May 2026 04:29:53 +0200 Subject: [PATCH 09/18] fix(plugin): scope sys.exit(2) check to Bash-matcher commands only checkPluginHooks() was stringifying the entire PreToolUse JSON and searching for sys.exit(2) anywhere in it. This would produce a false positive if any non-Bash hook happened to contain that string in a non-command field. Now extracts only the `command` strings from hooks whose `matcher` is "Bash" and checks sys.exit(2) specifically in those. The || exit swallowed-failure check is updated to use the same filtered command list. Fixes Codex P2 thread on PR #507. --- scripts/check-claude-plugin.ts | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/scripts/check-claude-plugin.ts b/scripts/check-claude-plugin.ts index d9a1afc1d..8940bca50 100644 --- a/scripts/check-claude-plugin.ts +++ b/scripts/check-claude-plugin.ts @@ -247,16 +247,39 @@ function checkPluginHooks(): void { if (!hooksJson.hooks?.["PreToolUse"]) { errors.push("claude-plugin/specorator/hooks/hooks.json must define PreToolUse (branch guard requires it)"); } - const preToolUseStr = JSON.stringify(hooksJson.hooks?.["PreToolUse"] ?? ""); + + // 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 (!preToolUseStr.includes("sys.exit(2)")) { - errors.push("claude-plugin/specorator/hooks/hooks.json PreToolUse branch guard must use sys.exit(2) for blocking semantics"); + 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 (/\|\|\s*exit\s+\d/.test(preToolUseStr)) { + 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" From 1773e6cc59f6356869a2bedda8a332b11e6ff2a3 Mon Sep 17 00:00:00 2001 From: Luis Mendez <3923861+Luis85@users.noreply.github.com> Date: Thu, 14 May 2026 04:52:32 +0200 Subject: [PATCH 10/18] fix(plugin): strip quoted text before git-commit pattern match in PreToolUse hook --- claude-plugin/specorator/hooks/hooks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/claude-plugin/specorator/hooks/hooks.json b/claude-plugin/specorator/hooks/hooks.json index 88e2c0157..d844be90e 100644 --- a/claude-plugin/specorator/hooks/hooks.json +++ b/claude-plugin/specorator/hooks/hooks.json @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "python3 -c 'import sys,json,re,subprocess; d=json.load(sys.stdin); cmd=d.get(\"tool_input\",{}).get(\"command\",\"\"); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cmd) 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))'" + "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); cleaned=re.sub(dq+r\"[^\"+dq+r\"]*\"+dq+r\"|\"+sq+r\"[^\"+sq+r\"]*\"+sq,\"\",cmd); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cleaned) 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))' 2>/dev/null || exit 0" } ] } From 3047630d16cc3e8dd44b8f302a16d2e40ff79876 Mon Sep 17 00:00:00 2001 From: Luis Mendez <3923861+Luis85@users.noreply.github.com> Date: Thu, 14 May 2026 04:59:04 +0200 Subject: [PATCH 11/18] fix(plugin): remove || exit 0 from branch guard; guard skills dir type --- claude-plugin/specorator/hooks/hooks.json | 2 +- scripts/check-claude-plugin.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/claude-plugin/specorator/hooks/hooks.json b/claude-plugin/specorator/hooks/hooks.json index d844be90e..4bbdf9884 100644 --- a/claude-plugin/specorator/hooks/hooks.json +++ b/claude-plugin/specorator/hooks/hooks.json @@ -17,7 +17,7 @@ "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); cleaned=re.sub(dq+r\"[^\"+dq+r\"]*\"+dq+r\"|\"+sq+r\"[^\"+sq+r\"]*\"+sq,\"\",cmd); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cleaned) 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))' 2>/dev/null || exit 0" + "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); cleaned=re.sub(dq+r\"[^\"+dq+r\"]*\"+dq+r\"|\"+sq+r\"[^\"+sq+r\"]*\"+sq,\"\",cmd); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cleaned) 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))' 2>/dev/null" } ] } diff --git a/scripts/check-claude-plugin.ts b/scripts/check-claude-plugin.ts index 8940bca50..075e71883 100644 --- a/scripts/check-claude-plugin.ts +++ b/scripts/check-claude-plugin.ts @@ -299,6 +299,7 @@ function checkPluginSettings(): void { function warnMissingSkillDescriptions(): void { const skillsDir = path.join(pluginRoot, "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) => f.endsWith(".md"))) { const text = readText(skillPath); From dcfc393f6090afb511260681a05806e28253a5b9 Mon Sep 17 00:00:00 2001 From: Luis Mendez <3923861+Luis85@users.noreply.github.com> Date: Thu, 14 May 2026 12:03:37 +0200 Subject: [PATCH 12/18] fix(claude-plugin): remove stderr suppression and ungate skill description check hooks.json: remove `2>/dev/null` so branch-guard stderr is visible to the user when a commit is blocked on main/develop. check-claude-plugin.ts: move warnMissingSkillDescriptions() to the always-run section and point it at .claude/skills/ (the canonical source) instead of the gitignored generated bundle, so it runs on clean checkouts. Resolves reviewer threads on PR #507. --- claude-plugin/specorator/hooks/hooks.json | 2 +- scripts/check-claude-plugin.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/claude-plugin/specorator/hooks/hooks.json b/claude-plugin/specorator/hooks/hooks.json index 4bbdf9884..db38b21ed 100644 --- a/claude-plugin/specorator/hooks/hooks.json +++ b/claude-plugin/specorator/hooks/hooks.json @@ -17,7 +17,7 @@ "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); cleaned=re.sub(dq+r\"[^\"+dq+r\"]*\"+dq+r\"|\"+sq+r\"[^\"+sq+r\"]*\"+sq,\"\",cmd); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cleaned) 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))' 2>/dev/null" + "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); cleaned=re.sub(dq+r\"[^\"+dq+r\"]*\"+dq+r\"|\"+sq+r\"[^\"+sq+r\"]*\"+sq,\"\",cmd); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cleaned) 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/scripts/check-claude-plugin.ts b/scripts/check-claude-plugin.ts index 075e71883..a607d9c45 100644 --- a/scripts/check-claude-plugin.ts +++ b/scripts/check-claude-plugin.ts @@ -37,6 +37,7 @@ 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 @@ -66,7 +67,6 @@ if (presentCount > 0 && presentCount < generatedPaths.length) { // hand-edited bundles pass even though they will diverge from what the CI // publish workflow rebuilds (Codex P2 on PR #478). Run the byte-level // comparison here only when the full bundle is present. - warnMissingSkillDescriptions(); checkBundleDrift(); } @@ -297,7 +297,7 @@ function checkPluginSettings(): void { } function warnMissingSkillDescriptions(): void { - const skillsDir = path.join(pluginRoot, "skills"); + const skillsDir = path.join(repoRoot, ".claude", "skills"); if (!fs.existsSync(skillsDir)) return; if (!fs.statSync(skillsDir).isDirectory()) return; const skillsDirRel = path.relative(repoRoot, skillsDir); From 0d0545803bf7252347e9220c0ad99717c0758b51 Mon Sep 17 00:00:00 2001 From: Luis Mendez <3923861+Luis85@users.noreply.github.com> Date: Thu, 14 May 2026 13:18:11 +0200 Subject: [PATCH 13/18] fix(plugin): search raw cmd in branch guard to catch quoted wrapper commits --- claude-plugin/specorator/hooks/hooks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/claude-plugin/specorator/hooks/hooks.json b/claude-plugin/specorator/hooks/hooks.json index db38b21ed..88e2c0157 100644 --- a/claude-plugin/specorator/hooks/hooks.json +++ b/claude-plugin/specorator/hooks/hooks.json @@ -17,7 +17,7 @@ "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); cleaned=re.sub(dq+r\"[^\"+dq+r\"]*\"+dq+r\"|\"+sq+r\"[^\"+sq+r\"]*\"+sq,\"\",cmd); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cleaned) 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))'" + "command": "python3 -c 'import sys,json,re,subprocess; d=json.load(sys.stdin); cmd=d.get(\"tool_input\",{}).get(\"command\",\"\"); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cmd) 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))'" } ] } From 750c274193552109c2d18bba7a65586fe7a0c872 Mon Sep 17 00:00:00 2001 From: Luis Mendez <3923861+Luis85@users.noreply.github.com> Date: Thu, 14 May 2026 13:46:50 +0200 Subject: [PATCH 14/18] fix(hooks): restore quote-stripping to prevent false positives on echo "git commit" --- claude-plugin/specorator/hooks/hooks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/claude-plugin/specorator/hooks/hooks.json b/claude-plugin/specorator/hooks/hooks.json index 88e2c0157..e2f8e755f 100644 --- a/claude-plugin/specorator/hooks/hooks.json +++ b/claude-plugin/specorator/hooks/hooks.json @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "python3 -c 'import sys,json,re,subprocess; d=json.load(sys.stdin); cmd=d.get(\"tool_input\",{}).get(\"command\",\"\"); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cmd) 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))'" + "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); cleaned=re.sub(f\"{dq}[^{dq}]*{dq}|{sq}[^{sq}]*{sq}\",\"\",cmd); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cleaned) 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))'" } ] } From adc8cd577ea707dfb108bb53aec18480a7d2a8cb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 12:50:14 +0000 Subject: [PATCH 15/18] fix(hooks): detect quoted shell-wrapper commits; anchor git to command position - Also check inside bash/sh -c '...' quoted args so commands like `bash -lc "git commit"` are caught on main/develop. - Anchor the git-commit regex to command-position tokens (^, |, &, ;, (, {, \n) so benign commands like `echo git commit` no longer trigger false positives. - quote-stripping is preserved for the direct-invocation path so `echo "git commit"` still produces no false positive. https://claude.ai/code/session_011TPNgd7jBv3ySSyvaTifA1 --- claude-plugin/specorator/hooks/hooks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/claude-plugin/specorator/hooks/hooks.json b/claude-plugin/specorator/hooks/hooks.json index e2f8e755f..230698449 100644 --- a/claude-plugin/specorator/hooks/hooks.json +++ b/claude-plugin/specorator/hooks/hooks.json @@ -17,7 +17,7 @@ "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); cleaned=re.sub(f\"{dq}[^{dq}]*{dq}|{sq}[^{sq}]*{sq}\",\"\",cmd); re.search(r\"\\bgit\\b[^|]*\\scommit\\b\",cleaned) 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))'" + "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)\\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))'" } ] } From 02e778f600eedc9856afe380dac90e2cb3f4a076 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 12:59:36 +0000 Subject: [PATCH 16/18] fix(hooks): broaden commit anchor to cover env-var and wrapper prefixes Extend the command-position anchor in is_gc so that git commit is also detected when preceded by env-var assignments (FOO=bar) or known passthrough wrappers (env, sudo, time, nice, nohup). The previous pattern required git to appear immediately after ^, a pipe, semicolon, etc., so `env FOO=1 git commit` was silently allowed on protected branches. echo git commit (literal print) remains undetected because echo does not match any of the allowed prefix patterns. --- claude-plugin/specorator/hooks/hooks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/claude-plugin/specorator/hooks/hooks.json b/claude-plugin/specorator/hooks/hooks.json index 230698449..5a6d5936a 100644 --- a/claude-plugin/specorator/hooks/hooks.json +++ b/claude-plugin/specorator/hooks/hooks.json @@ -17,7 +17,7 @@ "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)\\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))'" + "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)\\s*(?:(?:[A-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))'" } ] } From 1f58c734216ba385d570564488764a877529a20b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 14:05:18 +0000 Subject: [PATCH 17/18] fix(plugin): broaden env-var regex; narrow skill-desc scan to SKILL.md; fix TS6 types - Extend PreToolUse env-var bypass regex to cover lowercase env-var names - Narrow warnMissingSkillDescriptions() scan from *.md to SKILL.md only - Add "types":["node"] to tsconfig.scripts.json for TypeScript 6 compatibility https://claude.ai/code/session_011TPNgd7jBv3ySSyvaTifA1 --- claude-plugin/specorator/hooks/hooks.json | 2 +- scripts/check-claude-plugin.ts | 2 +- tsconfig.scripts.json | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/claude-plugin/specorator/hooks/hooks.json b/claude-plugin/specorator/hooks/hooks.json index 5a6d5936a..5d12fd9f9 100644 --- a/claude-plugin/specorator/hooks/hooks.json +++ b/claude-plugin/specorator/hooks/hooks.json @@ -17,7 +17,7 @@ "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)\\s*(?:(?:[A-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))'" + "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)\\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/scripts/check-claude-plugin.ts b/scripts/check-claude-plugin.ts index a607d9c45..f904124d4 100644 --- a/scripts/check-claude-plugin.ts +++ b/scripts/check-claude-plugin.ts @@ -301,7 +301,7 @@ function warnMissingSkillDescriptions(): void { if (!fs.existsSync(skillsDir)) return; if (!fs.statSync(skillsDir).isDirectory()) return; const skillsDirRel = path.relative(repoRoot, skillsDir); - for (const skillPath of walkFiles(skillsDirRel, (f) => f.endsWith(".md"))) { + for (const skillPath of walkFiles(skillsDirRel, (f) => path.basename(f) === "SKILL.md")) { const text = readText(skillPath); if (!text.startsWith("---\n")) continue; const endFm = text.indexOf("\n---\n", 4); diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json index 764418eea..432af0b95 100644 --- a/tsconfig.scripts.json +++ b/tsconfig.scripts.json @@ -7,7 +7,8 @@ "strict": true, "noEmit": true, "skipLibCheck": true, - "allowImportingTsExtensions": true + "allowImportingTsExtensions": true, + "types": ["node"] }, "include": ["scripts/**/*.ts", "tests/scripts/**/*.test.ts"], "exclude": [ From 98fdd9ee12a142a57216c1311d99f42aca902c3c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 16:01:39 +0000 Subject: [PATCH 18/18] fix(plugin): warn for SKILL.md without frontmatter; broaden branch-guard to catch then-clause commits warnMissingSkillDescriptions() now warns for SKILL.md files that have no frontmatter at all (not just files with frontmatter that lack description:). The branch-guard is_gc regex now recognises 'then' as a command separator so 'if ...; then git commit ...; fi' is correctly detected on protected branches. https://claude.ai/code/session_011TPNgd7jBv3ySSyvaTifA1 --- claude-plugin/specorator/hooks/hooks.json | 2 +- scripts/check-claude-plugin.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/claude-plugin/specorator/hooks/hooks.json b/claude-plugin/specorator/hooks/hooks.json index 5d12fd9f9..758da005c 100644 --- a/claude-plugin/specorator/hooks/hooks.json +++ b/claude-plugin/specorator/hooks/hooks.json @@ -17,7 +17,7 @@ "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)\\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))'" + "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/scripts/check-claude-plugin.ts b/scripts/check-claude-plugin.ts index f904124d4..ef5cee6ca 100644 --- a/scripts/check-claude-plugin.ts +++ b/scripts/check-claude-plugin.ts @@ -303,7 +303,10 @@ function warnMissingSkillDescriptions(): void { 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")) continue; + 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);