Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
679a919
feat(plugin): add hooks, settings, extend validator (#447 #448 #451)
claude May 13, 2026
0ffbb3f
fix(plugin): remove || exit 0 from branch guard; tighten validator
Luis85 May 14, 2026
c0f4f93
fix(plugin): validate PreToolUse hook independently; use stderr for b…
Luis85 May 14, 2026
5ce26a3
fix(plugin): require sys.exit(2) in PreToolUse hook — catch empty/mis…
Luis85 May 14, 2026
ada954b
fix(plugin): nest hooks.json under hooks key, fix validator path
Symprowire May 14, 2026
482c22c
fix(tests): wrap hooksFixture under hooks key to match updated schema
Luis85 May 14, 2026
be4ee51
fix(plugin): remove quote-stripping bypass from branch-guard hook
Luis85 May 14, 2026
2c3d4d1
fix(plugin): correct $schema URL in buildExpectedManifest
Luis85 May 14, 2026
4b20e94
fix(plugin): scope sys.exit(2) check to Bash-matcher commands only
Luis85 May 14, 2026
706028d
Merge branch 'develop' into feat/p1-plugin-foundations
Luis85 May 14, 2026
1773e6c
fix(plugin): strip quoted text before git-commit pattern match in Pre…
Luis85 May 14, 2026
3047630
fix(plugin): remove || exit 0 from branch guard; guard skills dir type
Luis85 May 14, 2026
dcfc393
fix(claude-plugin): remove stderr suppression and ungate skill descri…
Luis85 May 14, 2026
61f4eb1
Merge branch 'develop' into feat/p1-plugin-foundations
Luis85 May 14, 2026
bdcc09d
Merge branch 'develop' into feat/p1-plugin-foundations
Luis85 May 14, 2026
ee8fe76
Merge branch 'develop' into feat/p1-plugin-foundations
Luis85 May 14, 2026
0d05458
fix(plugin): search raw cmd in branch guard to catch quoted wrapper c…
Luis85 May 14, 2026
750c274
fix(hooks): restore quote-stripping to prevent false positives on ech…
Luis85 May 14, 2026
adc8cd5
fix(hooks): detect quoted shell-wrapper commits; anchor git to comman…
claude May 14, 2026
02e778f
fix(hooks): broaden commit anchor to cover env-var and wrapper prefixes
claude May 14, 2026
1f58c73
fix(plugin): broaden env-var regex; narrow skill-desc scan to SKILL.m…
claude May 14, 2026
98fdd9e
fix(plugin): warn for SKILL.md without frontmatter; broaden branch-gu…
claude May 14, 2026
c6e254c
Merge branch 'develop' into feat/p1-plugin-foundations
Luis85 May 14, 2026
aaa0819
Merge branch 'develop' into feat/p1-plugin-foundations
Luis85 May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"plugins": [
{
"name": "specorator",
"category": "development",
"source": {
"source": "git-subdir",
"url": "https://github.com/Luis85/agentic-workflow.git",
Expand Down
26 changes: 26 additions & 0 deletions claude-plugin/specorator/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -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))'"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Detect commits prefixed by command in branch guard

The is_gc matcher only allows a small prefix list (env|sudo|time|nice|nohup plus assignments), so command git commit -m ... is treated as non-commit and exits 0 even on main/develop, which bypasses the protected-branch block. Fresh evidence: evaluating the committed regex against command git commit -m x returns no match, while plain git commit -m x matches. Because command is a valid shell prefix that still executes git commit, this leaves a direct path to commit on protected branches.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Detect option-bearing sudo/time prefixes in commit matcher

The branch-guard regex only allows bare sudo/time tokens before git commit, so valid Bash forms with flags (for example sudo -u root git commit -m x or time -p git commit -m x) are treated as non-commit commands. In this commit’s hook command, those inputs return exit 0 on a main branch, which bypasses the protected-branch commit block entirely.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Match git commits inside do ... done shell blocks

The commit detector adds a special-case boundary for then but not for loop bodies, so constructs like for i in 1; do git commit -m x; done are not recognized as commit invocations. With the current hook command this path exits 0 on main/develop, allowing direct commits through a common shell control-flow form.

Useful? React with 👍 / 👎.

}
]
}
]
}
}
3 changes: 3 additions & 0 deletions claude-plugin/specorator/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"agent": "orchestrator"
}
1 change: 1 addition & 0 deletions scripts/build-claude-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
87 changes: 86 additions & 1 deletion scripts/check-claude-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -109,6 +113,7 @@ function checkPluginManifest(): void {
const manifest = readJson<PluginManifest>(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");
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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<string, unknown> }>(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<string, unknown> =>
!!h && typeof h === "object" && !Array.isArray(h) &&
(h as Record<string, unknown>)["matcher"] === "Bash"
)
.flatMap((h) => {
const inner = h["hooks"];
return Array.isArray(inner)
? inner
.filter((ih: unknown): ih is Record<string, unknown> =>
!!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 <N> — " +
"this swallows the blocking exit code and neutralises branch protection"
);
}
}

function checkPluginSettings(): void {
const settingsPath = path.join(pluginRoot, "settings.json");
const settings = readJson<Record<string, unknown>>(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`);
}
}
}
24 changes: 24 additions & 0 deletions tests/scripts/claude-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand All @@ -241,6 +245,7 @@ function marketplaceFixture() {
plugins: [
{
name: "specorator",
category: "development",
source: {
source: "git-subdir",
url: "https://github.com/Luis85/agentic-workflow.git",
Expand All @@ -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-"));
}
Expand Down
Loading