diff --git a/.github/workflows/marketplace-consistency.yml b/.github/workflows/marketplace-consistency.yml new file mode 100644 index 0000000..c9e7217 --- /dev/null +++ b/.github/workflows/marketplace-consistency.yml @@ -0,0 +1,40 @@ +# Eventual-consistency workaround for GitHub Actions reliability issues on +# auto-update-marketplace PRs. Not a replacement for the normal publish flow — +# only unsticks PRs whose workflow runs failed to start or got hung up by +# GH-side flake. See agentic-marketplace/heal-stuck-prs/README.md. +name: Marketplace Consistency + +on: + workflow_call: + inputs: + branch: + description: 'Branch used by publish/action.yml for auto-generated PRs.' + default: 'auto-update-marketplace' + type: string + label: + description: 'Label publish/action.yml sets on auto-generated PRs.' + default: 'automated' + type: string + stuck-threshold-seconds: + description: 'Age threshold (seconds) above which a PR/job is stuck.' + default: '90' + type: string + secrets: + token: + description: 'GITHUB_TOKEN is sufficient. Needs contents:write, pull-requests:write, actions:write.' + required: true + +jobs: + heal: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + actions: write + steps: + - uses: bitcomplete/bc-github-actions/agentic-marketplace/heal-stuck-prs@v1 + with: + github-token: ${{ secrets.token }} + branch: ${{ inputs.branch }} + label: ${{ inputs.label }} + stuck-threshold-seconds: ${{ inputs.stuck-threshold-seconds }} diff --git a/agentic-marketplace/README.md b/agentic-marketplace/README.md index 2429837..9d011ff 100644 --- a/agentic-marketplace/README.md +++ b/agentic-marketplace/README.md @@ -167,32 +167,28 @@ Generates marketplace.json and plugin.json files, then creates a pull request wi The marketplace actions are configured via a TOML file at `.claude-plugin/generator.config.toml`: ```toml -# Naming pattern for components -naming_pattern = "^[a-z0-9]+(-[a-z0-9]+)*$" # kebab-case +[discovery] +# Directories the scanner will not enter. +excludeDirs = [".git", "node_modules", ".github", ".claude", "templates"] -# Reserved words that cannot appear in component names -reserved_words = ["anthropic", "claude"] +# Glob patterns to skip (case-insensitive). +excludePatterns = ["**/template/**", "OPENCODE.md", "CLAUDE.md"] -# Plugin discovery paths (glob patterns) -plugin_categories = ["code/**", "analysis/**", "communication/**"] +# Skill definition filename. +skillFilename = "SKILL.md" -# Component types to discover -[discovery] -plugins = true -commands = true -skills = true -agents = true -hooks = true -mcp_servers = true - -# Validation rules [validation] -require_description = true -require_version = true -min_description_length = 10 -max_description_length = 200 +# Kebab-case by default. +namePattern = "^[a-z0-9]+(-[a-z0-9]+)*$" +reservedWords = ["anthropic", "claude"] +nameMaxLength = 64 +descriptionMaxLength = 1024 ``` +Discovery is excludes-only: components are any `//` directory that isn't excluded. There is no include list. Any top-level directory that contains components becomes a category. + +> **Deprecated:** older configs set `pluginCategories = ["code/**", ...]` as an include list. The field is still accepted but has no effect — discovery now uses `excludeDirs` / `excludePatterns` only. Remove it when you touch the config. + ## Repository Structure The marketplace action expects this structure: @@ -224,7 +220,7 @@ your-marketplace/ ### Discovery Process -The discover action scans your repository based on the plugin_categories patterns in your config: +The discover action walks your repository from root and gates only on `excludeDirs` / `excludePatterns`: 1. Finds all directories matching the two-level pattern: `category/plugin-name/` 2. Scans each plugin directory for: @@ -236,6 +232,8 @@ The discover action scans your repository based on the plugin_categories pattern 3. Extracts metadata from YAML frontmatter in markdown files 4. Outputs discovered components as JSON +Validate and generate consume the exact same discovery output. A component that passes validate will appear in generate's marketplace.json by construction. Components found outside a `category/plugin-name/` path (e.g. at repo root) are orphans and fail both stages. + ### Validation Process The validate action checks each component against your configuration rules: @@ -263,8 +261,8 @@ The generate action creates or updates marketplace files: ### No components discovered Check that: -- Your `generator.config.toml` `plugin_categories` patterns match your directory structure - Plugin directories follow the two-level pattern: `category/plugin-name/` +- The plugin's directory isn't covered by `excludeDirs` / `excludePatterns` - Component files have proper YAML frontmatter ### Validation failures @@ -334,6 +332,12 @@ Use validation output to implement custom logic: echo '${{ steps.validate.outputs.errors }}' ``` +## Reliability + +### heal-stuck-prs + +Works around GitHub Actions infrastructure flake on auto-update-marketplace PRs. Scans for PRs stuck with no checks attached, jobs stuck in `queued` state, or stalled auto-merge, and applies targeted recovery. Runs on a cron schedule — see [`heal-stuck-prs/README.md`](heal-stuck-prs/README.md). This is not a replacement for the normal publish flow; only reach for it when GH-side flake has left a PR hanging. + ## Examples See the [main README](../README.md) for complete workflow examples and diagrams. diff --git a/agentic-marketplace/heal-stuck-prs/README.md b/agentic-marketplace/heal-stuck-prs/README.md new file mode 100644 index 0000000..05ff77c --- /dev/null +++ b/agentic-marketplace/heal-stuck-prs/README.md @@ -0,0 +1,73 @@ +# Heal Stuck Marketplace PRs + +**This action exists to work around GitHub Actions reliability issues.** It is not a general automation layer, does not replace the normal `agentic-marketplace` publish flow, and should not be reached for when the normal workflow could already handle the situation. + +## What it does + +Scans the repo it runs in for open PRs on the `auto-update-marketplace` branch (the one [`agentic-marketplace/publish`](../publish/action.yml) creates) that have gotten stuck due to GH-side flake, and applies a narrowly targeted recovery: + +| Classification | Symptom | Remediation | +|---|---|---| +| **No workflow runs** | PR has no runs attached to its head SHA and is older than the threshold | Push an empty commit to the PR branch to retrigger `pull_request` workflows; re-issue auto-merge | +| **Queued jobs** | Any job older than the threshold with `started_at = null` | Cancel the stuck run, re-run it, re-issue auto-merge | +| **Stalled auto-merge** | All checks green but `autoMergeRequest` is unset | Re-issue `gh pr merge --auto --squash` | +| **Healthy** | Everything in the expected envelope | Log, skip | + +## When it runs + +Intended to be invoked on a cron schedule (every 30 min is a sensible default) via the [`marketplace-consistency.yml`](../../.github/workflows/marketplace-consistency.yml) reusable workflow. Runs are lightweight and no-op on healthy repos. + +## Stuck threshold + +Defaults to **90 seconds**. Derived by sampling `started_at − created_at` (runner-wait time) across a spread of recent healthy `Update Agentic Marketplace` runs on a reference repo — the max observed was 9s. 10× the ceiling gives a threshold that is comfortably outside the healthy operating envelope. + +Override via `stuck-threshold-seconds` if your repo's workflows have different queue-time characteristics. + +## Inputs + +| Name | Required | Default | Description | +|---|---|---|---| +| `github-token` | yes | — | `GITHUB_TOKEN` is sufficient. Needs `contents:write`, `pull-requests:write`, `actions:write`. | +| `branch` | no | `auto-update-marketplace` | Branch that `publish/action.yml` uses for auto-generated PRs. | +| `label` | no | `automated` | Label that `publish/action.yml` puts on auto-generated PRs. | +| `stuck-threshold-seconds` | no | `90` | Age above which a PR/job is considered stuck. | + +## Usage + +Direct: + +```yaml +- uses: bitcomplete/bc-github-actions/agentic-marketplace/heal-stuck-prs@v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} +``` + +Via the reusable workflow (recommended): + +```yaml +name: Marketplace Consistency Cron +on: + schedule: + - cron: '*/30 * * * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + actions: write + +jobs: + heal: + uses: bitcomplete/bc-github-actions/.github/workflows/marketplace-consistency.yml@v1 + secrets: + token: ${{ secrets.GITHUB_TOKEN }} +``` + +## Why this exists + +When GitHub Actions infrastructure is healthy, `agentic-marketplace/publish` opens the auto-update PR, the PR's `pull_request` workflow picks up, checks go green, auto-merge fires — all in under a minute. When infrastructure is flaky, one of two things happens: + +1. The `pull_request` workflow never triggers, so the PR has zero checks and auto-merge can't evaluate. Previously this left the PR blocked until someone noticed and closed it manually (see historical PRs #39, #40). +2. A job enters `queued` state and never gets a runner. The PR sits indefinitely waiting for checks that will never complete. + +This action catches both cases after the fact, without replacing any of the normal flow. diff --git a/agentic-marketplace/heal-stuck-prs/action.yml b/agentic-marketplace/heal-stuck-prs/action.yml new file mode 100644 index 0000000..2ff463e --- /dev/null +++ b/agentic-marketplace/heal-stuck-prs/action.yml @@ -0,0 +1,119 @@ +name: 'Heal Stuck Marketplace PRs' +description: 'Eventual-consistency workaround for GitHub Actions reliability flake on auto-update-marketplace PRs. Not a general automation layer.' +author: 'Bitcomplete' + +inputs: + github-token: + description: 'GitHub token (GITHUB_TOKEN is fine — needs contents:write, pull-requests:write, actions:write).' + required: true + branch: + description: 'Branch name used by publish/action.yml for auto-generated PRs.' + required: false + default: 'auto-update-marketplace' + label: + description: 'Label used by publish/action.yml to mark auto-generated PRs.' + required: false + default: 'automated' + stuck-threshold-seconds: + description: 'A PR or job older than this (seconds) with no check runs, or with jobs still in queued/pending state, is considered stuck. 90s = 10x the observed runner-wait ceiling in healthy runs.' + required: false + default: '90' + +runs: + using: 'composite' + steps: + - name: Heal stuck PRs + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + HEAL_BRANCH: ${{ inputs.branch }} + HEAL_LABEL: ${{ inputs.label }} + HEAL_THRESHOLD: ${{ inputs.stuck-threshold-seconds }} + run: | + set -euo pipefail + + REPO="${GITHUB_REPOSITORY}" + NOW=$(date -u +%s) + + echo "heal-stuck-prs: scanning $REPO for open PRs on branch=$HEAL_BRANCH label=$HEAL_LABEL (threshold=${HEAL_THRESHOLD}s)" + + # Enumerate candidate PRs: open, on our auto-update branch, with our label. + PRS=$(gh pr list \ + --repo "$REPO" \ + --state open \ + --json number,createdAt,headRefName,headRefOid,labels,autoMergeRequest \ + --jq "[.[] | select(.headRefName == \"$HEAL_BRANCH\" and ((.labels // []) | map(.name) | index(\"$HEAL_LABEL\")))]") + + COUNT=$(echo "$PRS" | jq 'length') + if [ "$COUNT" = "0" ]; then + echo "heal-stuck-prs: no candidate PRs — nothing to heal" + exit 0 + fi + + echo "heal-stuck-prs: $COUNT candidate PR(s) to classify" + + echo "$PRS" | jq -c '.[]' | while read -r PR; do + PR_NUM=$(echo "$PR" | jq -r '.number') + PR_SHA=$(echo "$PR" | jq -r '.headRefOid') + PR_CREATED=$(echo "$PR" | jq -r '.createdAt') + PR_AUTO_MERGE=$(echo "$PR" | jq -r '.autoMergeRequest // "null"') + PR_AGE=$(( NOW - $(date -u -d "$PR_CREATED" +%s) )) + + # Jobs for all workflow runs associated with this head SHA. + RUNS=$(gh api "repos/$REPO/actions/runs?head_sha=$PR_SHA&per_page=100" \ + --jq '[.workflow_runs[] | {id, status, conclusion}]' 2>/dev/null || echo '[]') + RUN_COUNT=$(echo "$RUNS" | jq 'length') + + # Aggregate job state: count stuck-in-queue jobs across all runs. + STUCK_JOBS=0 + STUCK_RUN_ID="" + if [ "$RUN_COUNT" -gt 0 ]; then + for RUN_ID in $(echo "$RUNS" | jq -r '.[].id'); do + JOBS=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs" --jq '[.jobs[] | {status, created_at, started_at}]' 2>/dev/null || echo '[]') + N_STUCK=$(echo "$JOBS" | jq --argjson threshold "$HEAL_THRESHOLD" --argjson now "$NOW" ' + [.[] | select(.started_at == null and .status != "completed") + | select(($now - (.created_at | fromdateiso8601)) > $threshold)] | length') + if [ "$N_STUCK" -gt 0 ]; then + STUCK_JOBS=$(( STUCK_JOBS + N_STUCK )) + STUCK_RUN_ID="$RUN_ID" + fi + done + fi + + # Classify and act. + if [ "$RUN_COUNT" = "0" ] && [ "$PR_AGE" -gt "$HEAL_THRESHOLD" ]; then + echo "heal-stuck-prs PR #$PR_NUM: [stuck — no workflow runs for head SHA, age=${PR_AGE}s] pushing empty commit to retrigger" + TMPDIR=$(mktemp -d) + git clone --depth 1 --branch "$HEAL_BRANCH" "https://x-access-token:$GH_TOKEN@github.com/$REPO.git" "$TMPDIR/repo" + pushd "$TMPDIR/repo" >/dev/null + git -c user.email="github-actions[bot]@users.noreply.github.com" -c user.name="github-actions[bot]" \ + commit --allow-empty -m "chore: retrigger workflows (heal-stuck-prs)" + git push origin "$HEAL_BRANCH" + popd >/dev/null + rm -rf "$TMPDIR" + gh pr merge "$PR_NUM" --repo "$REPO" --auto --squash || echo "heal-stuck-prs PR #$PR_NUM: auto-merge re-enable deferred (checks not attached yet)" + continue + fi + + if [ "$STUCK_JOBS" -gt 0 ]; then + echo "heal-stuck-prs PR #$PR_NUM: [stuck — $STUCK_JOBS job(s) queued >${HEAL_THRESHOLD}s] cancelling run $STUCK_RUN_ID and re-running" + gh run cancel "$STUCK_RUN_ID" --repo "$REPO" || true + gh run rerun "$STUCK_RUN_ID" --repo "$REPO" --failed || gh run rerun "$STUCK_RUN_ID" --repo "$REPO" || true + gh pr merge "$PR_NUM" --repo "$REPO" --auto --squash || true + continue + fi + + if [ "$PR_AUTO_MERGE" = "null" ]; then + echo "heal-stuck-prs PR #$PR_NUM: [stalled — auto-merge not set] re-enabling" + gh pr merge "$PR_NUM" --repo "$REPO" --auto --squash || echo "heal-stuck-prs PR #$PR_NUM: could not enable auto-merge" + continue + fi + + echo "heal-stuck-prs PR #$PR_NUM: [healthy] skip (age=${PR_AGE}s, runs=$RUN_COUNT)" + done + + echo "heal-stuck-prs: done" + +branding: + icon: 'activity' + color: 'yellow' diff --git a/scripts/dist/discover-components.cjs b/scripts/dist/discover-components.cjs index 6b2ac09..67353e7 100755 --- a/scripts/dist/discover-components.cjs +++ b/scripts/dist/discover-components.cjs @@ -7347,6 +7347,11 @@ function validateAndMergeConfig(defaults, config) { process.exit(1); } } + if (Array.isArray(config?.discovery?.pluginCategories) && config.discovery.pluginCategories.length > 0) { + console.warn( + "[WARN] generator.config: discovery.pluginCategories is deprecated and no longer has any effect. Discovery is excludes-only; remove the field." + ); + } return merged; } function classifyComponent(filePath, rootDir, config) { @@ -7842,17 +7847,8 @@ function discoverAllComponents(rootDir, config) { skipped: mdComponents.skipped }; } -function getCategoryNames(config) { - const defaultCategories = ["code", "analysis", "communication", "documents"]; - const globs = config.discovery.pluginCategories; - if (!globs || globs.length === 0) { - return defaultCategories; - } - return globs.map((glob) => glob.split("/")[0]).filter(Boolean); -} -function groupIntoPlugins(components, rootDir, config) { +function groupIntoPlugins(components, rootDir, _config) { const absoluteRoot = path.resolve(rootDir); - const validCategories = getCategoryNames(config); const pluginMap = /* @__PURE__ */ new Map(); const orphanedPaths = []; const allPaths = [ @@ -7869,10 +7865,6 @@ function groupIntoPlugins(components, rootDir, config) { } const category = parts[0]; const pluginName = parts[1]; - if (!validCategories.includes(category)) { - orphanedPaths.push(relPath); - continue; - } const key = `${category}/${pluginName}`; if (!pluginMap.has(key)) { pluginMap.set(key, { @@ -8031,7 +8023,6 @@ module.exports = { validateAgent, findDuplicateNames, discoverAllComponents, - getCategoryNames, groupIntoPlugins, discoverPlugins, extractPluginMetadata, @@ -8052,6 +8043,7 @@ if (require.main === module) { } else if (command === "validate") { const config = loadConfig(); const components = discoverAllComponents(".", config); + const { orphanedPaths } = groupIntoPlugins(components, ".", config); let hasErrors = false; if (components.errors.length > 0) { hasErrors = true; @@ -8062,6 +8054,12 @@ if (require.main === module) { `); }); } + if (orphanedPaths.length > 0) { + hasErrors = true; + console.error("\n[FAIL] Components not under a category/plugin-name/ path:"); + orphanedPaths.forEach((p) => console.error(` - ${p}`)); + console.error(" Move each component under // so it can be included in marketplace.json.\n"); + } console.log(`Found ${components.skills.length} skill(s) to validate `); const validatedSkills = []; @@ -8154,9 +8152,10 @@ Found ${components.agents.length} agent(s) to validate const components = discoverAllComponents(".", config); const { plugins, orphanedPaths } = groupIntoPlugins(components, ".", config); if (orphanedPaths.length > 0) { - console.warn("\n[WARN] Components not mapped to any plugin (not in a recognized category/plugin-name path):"); - orphanedPaths.forEach((p) => console.warn(` - ${p}`)); - console.warn(""); + console.error("\n[FAIL] Components not under a category/plugin-name/ path:"); + orphanedPaths.forEach((p) => console.error(` - ${p}`)); + console.error(" Refusing to write an incomplete marketplace.json. Run `validate` for details.\n"); + process.exit(1); } writePluginJsonFiles(plugins, config); const marketplace = generateMarketplace(plugins, config); diff --git a/scripts/src/discover-components.js b/scripts/src/discover-components.js index 33ed931..744ed9c 100644 --- a/scripts/src/discover-components.js +++ b/scripts/src/discover-components.js @@ -178,6 +178,16 @@ function validateAndMergeConfig(defaults, config) { } } + // Deprecation: pluginCategories used to be an include list; discovery is + // now excludes-only (excludeDirs/excludePatterns). Tolerate the field in + // existing configs so nothing breaks; warn so consumers migrate. + if (Array.isArray(config?.discovery?.pluginCategories) && config.discovery.pluginCategories.length > 0) { + console.warn( + '[WARN] generator.config: discovery.pluginCategories is deprecated and no longer has any effect. ' + + 'Discovery is excludes-only; remove the field.' + ); + } + return merged; } @@ -1084,39 +1094,22 @@ function findAssociatedJson(componentPath, jsonFiles) { return merged; } -/** - * Extracts valid category directory names from pluginCategories config globs. - * e.g. ["code/**", "analysis/**"] → ["code", "analysis"] - * Falls back to hardcoded defaults when not set. - * @param {Object} config - Configuration object - * @returns {string[]} Array of category directory names - */ -function getCategoryNames(config) { - const defaultCategories = ['code', 'analysis', 'communication', 'documents']; - const globs = config.discovery.pluginCategories; - - if (!globs || globs.length === 0) { - return defaultCategories; - } - - return globs.map(glob => glob.split('/')[0]).filter(Boolean); -} - /** * Groups discovered components into plugins by deriving plugin identity from paths. * Pure data transformation — no filesystem access. * * For each component path, extracts category/plugin-name from its path relative to rootDir. - * Components whose paths don't match category/plugin-name/... are returned as orphans. + * Components whose paths don't match category/plugin-name/... (e.g. files at repo root) + * are returned as orphans. Category is whatever top-level directory the component sits + * under; the excludeDirs/excludePatterns applied during discovery are the only gate. * * @param {Object} components - Output of discoverAllComponents() * @param {string} rootDir - Root directory (for computing relative paths) - * @param {Object} config - Configuration object + * @param {Object} _config - Configuration object (unused; kept for signature stability) * @returns {{ plugins: Array, orphanedPaths: string[] }} */ -function groupIntoPlugins(components, rootDir, config) { +function groupIntoPlugins(components, rootDir, _config) { const absoluteRoot = path.resolve(rootDir); - const validCategories = getCategoryNames(config); const pluginMap = new Map(); // key: "category/plugin-name" const orphanedPaths = []; @@ -1140,11 +1133,6 @@ function groupIntoPlugins(components, rootDir, config) { const category = parts[0]; const pluginName = parts[1]; - if (!validCategories.includes(category)) { - orphanedPaths.push(relPath); - continue; - } - const key = `${category}/${pluginName}`; if (!pluginMap.has(key)) { pluginMap.set(key, { @@ -1362,7 +1350,6 @@ module.exports = { validateAgent, findDuplicateNames, discoverAllComponents, - getCategoryNames, groupIntoPlugins, discoverPlugins, extractPluginMetadata, @@ -1386,6 +1373,7 @@ if (require.main === module) { } else if (command === 'validate') { const config = loadConfig(); const components = discoverAllComponents('.', config); + const { orphanedPaths } = groupIntoPlugins(components, '.', config); let hasErrors = false; // Check for classification errors first @@ -1398,6 +1386,16 @@ if (require.main === module) { }); } + // Orphans: discovered but not bucketed into a category/plugin-name directory. + // These would be silently dropped by generate, so fail here so the divergence + // between validate and generate cannot recur. + if (orphanedPaths.length > 0) { + hasErrors = true; + console.error('\n[FAIL] Components not under a category/plugin-name/ path:'); + orphanedPaths.forEach(p => console.error(` - ${p}`)); + console.error(' Move each component under // so it can be included in marketplace.json.\n'); + } + // Validate skills console.log(`Found ${components.skills.length} skill(s) to validate\n`); const validatedSkills = []; @@ -1502,9 +1500,10 @@ if (require.main === module) { const { plugins, orphanedPaths } = groupIntoPlugins(components, '.', config); if (orphanedPaths.length > 0) { - console.warn('\n[WARN] Components not mapped to any plugin (not in a recognized category/plugin-name path):'); - orphanedPaths.forEach(p => console.warn(` - ${p}`)); - console.warn(''); + console.error('\n[FAIL] Components not under a category/plugin-name/ path:'); + orphanedPaths.forEach(p => console.error(` - ${p}`)); + console.error(' Refusing to write an incomplete marketplace.json. Run `validate` for details.\n'); + process.exit(1); } // Write individual plugin.json files diff --git a/scripts/test/discover-components.test.js b/scripts/test/discover-components.test.js index 252189d..52e5601 100644 --- a/scripts/test/discover-components.test.js +++ b/scripts/test/discover-components.test.js @@ -14,7 +14,6 @@ const { classifyComponent, discoverMarkdownComponents, discoverAllComponents, - getCategoryNames, groupIntoPlugins, discoverPlugins, validateSkill, @@ -53,28 +52,6 @@ function loadFixtureConfig() { } } -// --- getCategoryNames --- - -console.log('\ngetCategoryNames'); - -test('extracts category names from globs', () => { - const config = { discovery: { pluginCategories: ['code/**', 'analysis/**'] } }; - const names = getCategoryNames(config); - assert.deepStrictEqual(names, ['code', 'analysis']); -}); - -test('returns defaults when pluginCategories is empty', () => { - const config = { discovery: { pluginCategories: [] } }; - const names = getCategoryNames(config); - assert.deepStrictEqual(names, ['code', 'analysis', 'communication', 'documents']); -}); - -test('returns defaults when pluginCategories is not set', () => { - const config = { discovery: {} }; - const names = getCategoryNames(config); - assert.deepStrictEqual(names, ['code', 'analysis', 'communication', 'documents']); -}); - // --- groupIntoPlugins --- console.log('\ngroupIntoPlugins'); @@ -122,11 +99,16 @@ test('groups a plugin with subdirectories correctly', () => { assert.strictEqual(orphanedPaths.length, 0); }); -test('respects pluginCategories filter — unrecognized categories become orphans', () => { +test('accepts any top-level directory as a category — no include filter', () => { + // Regression for the writing-coach bug: a component under a category not + // listed in pluginCategories used to be silently dropped. Discovery is now + // excludes-only; excludeDirs/excludePatterns are the only gate and run at + // the discovery stage (see discoverAllComponents). Anything reaching + // groupIntoPlugins is bucketed by its actual top-level directory. const rootDir = '/fake/root'; const config = { discovery: { pluginCategories: ['code/**'] } }; const components = { - skills: ['/fake/root/code/good-skill', '/fake/root/random/bad-skill'], + skills: ['/fake/root/code/good-skill', '/fake/root/communication/writing-coach'], commands: [], agents: [], hooksFiles: [], @@ -135,14 +117,16 @@ test('respects pluginCategories filter — unrecognized categories become orphan const { plugins, orphanedPaths } = groupIntoPlugins(components, rootDir, config); - assert.strictEqual(plugins.length, 1); - assert.strictEqual(plugins[0].name, 'good-skill'); - assert.deepStrictEqual(orphanedPaths, ['random/bad-skill']); + assert.strictEqual(plugins.length, 2); + const byName = Object.fromEntries(plugins.map(p => [p.name, p])); + assert.strictEqual(byName['good-skill'].category, 'code'); + assert.strictEqual(byName['writing-coach'].category, 'communication'); + assert.strictEqual(orphanedPaths.length, 0); }); test('components at repo root are orphaned', () => { const rootDir = '/fake/root'; - const config = { discovery: { pluginCategories: ['code/**'] } }; + const config = { discovery: {} }; const components = { skills: ['/fake/root/lonely-skill'], commands: [], @@ -157,6 +141,25 @@ test('components at repo root are orphaned', () => { assert.deepStrictEqual(orphanedPaths, ['lonely-skill']); }); +test('stale pluginCategories in config does not change behavior', () => { + // Even with a restrictive include list, components outside it must still be + // bucketed. The field is deprecated but tolerated. + const rootDir = '/fake/root'; + const config = { discovery: { pluginCategories: ['code/**'] } }; + const components = { + skills: ['/fake/root/documents/doc-skill'], + commands: [], + agents: [], + hooksFiles: [], + mcpFiles: [] + }; + + const { plugins, orphanedPaths } = groupIntoPlugins(components, rootDir, config); + assert.strictEqual(plugins.length, 1); + assert.strictEqual(plugins[0].category, 'documents'); + assert.strictEqual(orphanedPaths.length, 0); +}); + test('multiple plugins across categories', () => { const rootDir = '/fake/root'; const config = { discovery: { pluginCategories: ['code/**', 'analysis/**'] } };