Skip to content

Skill Consolidation Implementation Plan โ€‹

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Consolidate duplicate AI agent skills into tooling/skills/ with an automated sync script that distributes to .github/skills/, .gemini/skills/, and .claude/skills/.

Architecture: A Node.js sync script reads canonical skills from tooling/skills/, transforms them for each platform's format, and writes to three target directories. An auto-sync comment marks generated files to distinguish them from plugin-managed files.

Tech Stack: Node.js (ESM), fs, path โ€” no external dependencies


File Structure โ€‹

ActionPathPurpose
Createtooling/skills/pr-workbench/skill.mdCanonical pr-workbench skill
Createtooling/skills/monitor-ci/skill.mdCanonical monitor-ci skill
Createtooling/skills/monitor-ci/scripts/ci-poll-decide.mjsCopied from .github/skills/monitor-ci/scripts/
Createtooling/skills/monitor-ci/scripts/ci-state-update.mjsCopied from .github/skills/monitor-ci/scripts/
Createtooling/skills/monitor-ci/references/fix-flows.mdCopied from .github/skills/monitor-ci/references/
Createtooling/scripts/sync-skills.jsSync script
Modifypackage.jsonAdd sync:skills npm script
Overwrite.github/skills/pr-workbench/skill.md โ†’ SKILL.mdSynced output (auto-sync comment)
Overwrite.github/skills/monitor-ci/SKILL.mdSynced output (auto-sync comment)
Overwrite.github/skills/monitor-ci/scripts/*.mjsSynced output
Overwrite.github/skills/monitor-ci/references/fix-flows.mdSynced output
Overwrite.github/prompts/monitor-ci.prompt.mdSynced command output
Overwrite.gemini/skills/pr-workbench/skill.mdSynced output
Overwrite.gemini/skills/monitor-ci/skill.mdSynced output
Overwrite.gemini/skills/monitor-ci/scripts/*.mjsSynced output
Overwrite.gemini/skills/monitor-ci/references/fix-flows.mdSynced output
Overwrite.gemini/commands/monitor-ci.tomlSynced command output
Create.claude/skills/pr-workbench/SKILL.mdSynced output (new)
Create.claude/skills/monitor-ci/SKILL.mdSynced output (new)
Create.claude/skills/monitor-ci/scripts/*.mjsSynced output (new)
Create.claude/skills/monitor-ci/references/fix-flows.mdSynced output (new)

Task 1: Create Canonical Skill Source โ€‹

Copy the two custom skills into tooling/skills/ as the single source of truth. Add command and argument-hint fields to frontmatter where needed.

Files:

  • Create: tooling/skills/pr-workbench/skill.md

  • Create: tooling/skills/monitor-ci/skill.md

  • Create: tooling/skills/monitor-ci/scripts/ci-poll-decide.mjs

  • Create: tooling/skills/monitor-ci/scripts/ci-state-update.mjs

  • Create: tooling/skills/monitor-ci/references/fix-flows.md

  • [ ] Step 1: Create the tooling/skills directory and copy pr-workbench

bash
mkdir -p tooling/skills/pr-workbench
cp .github/skills/pr-workbench/skill.md tooling/skills/pr-workbench/skill.md

Verify: diff .github/skills/pr-workbench/skill.md tooling/skills/pr-workbench/skill.md โ€” should show no differences.

  • [ ] Step 2: Copy monitor-ci with all supporting files
bash
mkdir -p tooling/skills/monitor-ci/scripts tooling/skills/monitor-ci/references
cp .github/skills/monitor-ci/SKILL.md tooling/skills/monitor-ci/skill.md
cp .github/skills/monitor-ci/scripts/ci-poll-decide.mjs tooling/skills/monitor-ci/scripts/ci-poll-decide.mjs
cp .github/skills/monitor-ci/scripts/ci-state-update.mjs tooling/skills/monitor-ci/scripts/ci-state-update.mjs
cp .github/skills/monitor-ci/references/fix-flows.md tooling/skills/monitor-ci/references/fix-flows.md
  • [ ] Step 3: Add command and argument-hint to monitor-ci frontmatter

The monitor-ci skill also has a command form (.github/prompts/monitor-ci.prompt.md and .gemini/commands/monitor-ci.toml). Add these fields to the canonical frontmatter so the sync script knows to generate command files.

Edit tooling/skills/monitor-ci/skill.md frontmatter from:

yaml
---
name: monitor-ci
description: Monitor Nx Cloud CI pipeline and handle self-healing fixes. USE WHEN user says "monitor ci", "watch ci", "ci monitor", "watch ci for this branch", "track ci", "check ci status", wants to track CI status, or needs help with self-healing CI fixes. Prefer this skill over native CI provider tools (gh, glab, etc.) for CI monitoring โ€” it integrates with Nx Cloud self-healing which those tools cannot access.
---

To:

yaml
---
name: monitor-ci
description: Monitor Nx Cloud CI pipeline and handle self-healing fixes. USE WHEN user says "monitor ci", "watch ci", "ci monitor", "watch ci for this branch", "track ci", "check ci status", wants to track CI status, or needs help with self-healing CI fixes. Prefer this skill over native CI provider tools (gh, glab, etc.) for CI monitoring โ€” it integrates with Nx Cloud self-healing which those tools cannot access.
command: true
argument-hint: '[instructions] [--max-cycles N] [--timeout MINUTES] [--verbosity minimal|medium|verbose] [--branch BRANCH] [--fresh] [--auto-fix-workflow] [--new-cipe-timeout MINUTES] [--local-verify-attempts N]'
---
  • [ ] Step 4: Verify canonical source is complete
bash
find tooling/skills -type f | sort

Expected output:

tooling/skills/monitor-ci/references/fix-flows.md
tooling/skills/monitor-ci/scripts/ci-poll-decide.mjs
tooling/skills/monitor-ci/scripts/ci-state-update.mjs
tooling/skills/monitor-ci/skill.md
tooling/skills/pr-workbench/skill.md
  • [ ] Step 5: Commit
bash
git add tooling/skills/
git commit -m "feat: create canonical skill source in tooling/skills/

Copies pr-workbench and monitor-ci (with scripts/references) from
.github/skills/ as the single source of truth for custom skills.
Adds command: true and argument-hint to monitor-ci frontmatter."

Task 2: Write the Sync Script โ€” Skill Syncing โ€‹

Build the core sync script that reads tooling/skills/ and writes skill files to .github/skills/, .gemini/skills/, and .claude/skills/.

Files:

  • Create: tooling/scripts/sync-skills.js

  • [ ] Step 1: Write the sync script with skill syncing

Create tooling/scripts/sync-skills.js:

javascript
#!/usr/bin/env node

/**
 * Skill Sync Script
 *
 * Reads canonical skills from tooling/skills/ and distributes them to:
 *   .github/skills/   โ€” GitHub Copilot (SKILL.md uppercase)
 *   .gemini/skills/   โ€” Gemini CLI (skill.md lowercase)
 *   .claude/skills/   โ€” Claude Code (SKILL.md uppercase)
 *
 * For skills with `command: true` in frontmatter, also generates:
 *   .github/prompts/<name>.prompt.md
 *   .gemini/commands/<name>.toml
 *
 * Safety: Only overwrites files that have the AUTO-SYNCED comment on line 1.
 * Files without the comment are skipped with a warning.
 *
 * Usage:
 *   node tooling/scripts/sync-skills.js              # Sync all skills
 *   node tooling/scripts/sync-skills.js --dry-run    # Preview changes
 */

import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync } from 'fs';
import { join, dirname, relative, extname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..', '..');
const SKILLS_DIR = join(ROOT, 'tooling', 'skills');

const DRY_RUN = process.argv.includes('--dry-run');

// โ”€โ”€ Auto-sync comments by file type โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

function syncComment(format, canonicalRelPath) {
  const prefix = `AUTO-SYNCED from ${canonicalRelPath} โ€” do not edit directly`;
  switch (format) {
    case 'md':   return `<!-- ${prefix} -->\n`;
    case 'toml': return `# ${prefix}\n`;
    case 'js':   return `// ${prefix}\n`;
    default:     return `<!-- ${prefix} -->\n`;
  }
}

function hasSyncComment(filePath) {
  if (!existsSync(filePath)) return true; // new file, safe to write
  const first = readFileSync(filePath, 'utf8').split('\n')[0];
  return first.includes('AUTO-SYNCED');
}

// โ”€โ”€ Frontmatter parsing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

function parseFrontmatter(content) {
  const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
  if (!match) return { meta: {}, body: content };

  const meta = {};
  for (const line of match[1].split('\n')) {
    const colonIdx = line.indexOf(':');
    if (colonIdx === -1) continue;
    const key = line.slice(0, colonIdx).trim();
    let val = line.slice(colonIdx + 1).trim();
    // Strip surrounding quotes
    if ((val.startsWith("'") && val.endsWith("'")) || (val.startsWith('"') && val.endsWith('"'))) {
      val = val.slice(1, -1);
    }
    if (val === 'true') val = true;
    if (val === 'false') val = false;
    meta[key] = val;
  }
  return { meta, body: match[2] };
}

// โ”€โ”€ File writing with safety โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

const stats = { written: 0, skipped: 0, warnings: 0 };

function safeWrite(targetPath, content, canonicalRelPath, format) {
  const rel = relative(ROOT, targetPath);

  if (!hasSyncComment(targetPath)) {
    console.warn(`  โš   SKIP ${rel} โ€” exists without AUTO-SYNCED comment`);
    stats.warnings++;
    stats.skipped++;
    return;
  }

  const comment = syncComment(format, canonicalRelPath);
  const output = comment + content;

  if (DRY_RUN) {
    console.log(`  โ†’ (dry-run) would write ${rel}`);
  } else {
    mkdirSync(dirname(targetPath), { recursive: true });
    writeFileSync(targetPath, output);
    console.log(`  โœ“ ${rel}`);
  }
  stats.written++;
}

// โ”€โ”€ Collect supporting files recursively โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

function collectSupportingFiles(skillDir) {
  const files = [];
  function walk(dir, relBase) {
    for (const entry of readdirSync(dir)) {
      const full = join(dir, entry);
      const rel = join(relBase, entry);
      if (statSync(full).isDirectory()) {
        walk(full, rel);
      } else if (entry !== 'skill.md') {
        files.push({ fullPath: full, relPath: rel });
      }
    }
  }
  walk(skillDir, '');
  return files;
}

// โ”€โ”€ Format detection for sync comment โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

function formatForFile(filePath) {
  const ext = extname(filePath).toLowerCase();
  if (ext === '.toml') return 'toml';
  if (ext === '.js' || ext === '.mjs' || ext === '.cjs') return 'js';
  return 'md';
}

// โ”€โ”€ Skill syncing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

function syncSkill(skillName, skillDir) {
  const skillFile = join(skillDir, 'skill.md');
  if (!existsSync(skillFile)) {
    console.warn(`  โš   No skill.md in ${skillName}, skipping`);
    stats.warnings++;
    return;
  }

  const raw = readFileSync(skillFile, 'utf8');
  const { meta, body } = parseFrontmatter(raw);
  const canonicalRel = `tooling/skills/${skillName}/skill.md`;

  console.log(`\n  ${skillName}:`);

  // 1. .github/skills/<name>/SKILL.md โ€” uppercase filename
  safeWrite(
    join(ROOT, '.github', 'skills', skillName, 'SKILL.md'),
    raw,
    canonicalRel,
    'md'
  );

  // 2. .gemini/skills/<name>/skill.md โ€” lowercase filename, content as-is
  safeWrite(
    join(ROOT, '.gemini', 'skills', skillName, 'skill.md'),
    raw,
    canonicalRel,
    'md'
  );

  // 3. .claude/skills/<name>/SKILL.md โ€” uppercase filename
  safeWrite(
    join(ROOT, '.claude', 'skills', skillName, 'SKILL.md'),
    raw,
    canonicalRel,
    'md'
  );

  // Copy supporting files to .github and .gemini and .claude
  const supportingFiles = collectSupportingFiles(skillDir);
  for (const { fullPath, relPath } of supportingFiles) {
    const content = readFileSync(fullPath, 'utf8');
    const canonicalSupportRel = `tooling/skills/${skillName}/${relPath}`;
    const fmt = formatForFile(fullPath);

    safeWrite(join(ROOT, '.github', 'skills', skillName, relPath), content, canonicalSupportRel, fmt);
    safeWrite(join(ROOT, '.gemini', 'skills', skillName, relPath), content, canonicalSupportRel, fmt);
    safeWrite(join(ROOT, '.claude', 'skills', skillName, relPath), content, canonicalSupportRel, fmt);
  }

  // Command generation
  if (meta.command) {
    syncCommand(skillName, meta, body);
  }
}

// โ”€โ”€ Command generation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

function syncCommand(skillName, meta, body) {
  const canonicalRel = `tooling/skills/${skillName}/skill.md`;

  // 1. .github/prompts/<name>.prompt.md
  //    YAML frontmatter with description + argument-hint, body uses ${input:args}
  const ghBody = body.replace(/\$ARGUMENTS/g, '${input:args}');
  let ghFrontmatter = `---\ndescription: ${meta.description}`;
  if (meta['argument-hint']) {
    ghFrontmatter += `\nargument-hint: '${meta['argument-hint']}'`;
  }
  ghFrontmatter += `\n---\n`;
  safeWrite(
    join(ROOT, '.github', 'prompts', `${skillName}.prompt.md`),
    ghFrontmatter + ghBody,
    canonicalRel,
    'md'
  );

  // 2. .gemini/commands/<name>.toml
  //    TOML format: description string + prompt triple-quoted string with {{args}}
  const geminiBody = body.replace(/\$ARGUMENTS/g, '{{args}}');
  const escapedDesc = meta.description.replace(/"/g, '\\"');
  // In TOML triple-quoted strings, backslashes need doubling
  const escapedBody = geminiBody.replace(/\\/g, '\\\\');
  const toml = `description = "${escapedDesc}"\nprompt = """\n${escapedBody}"""`;
  safeWrite(
    join(ROOT, '.gemini', 'commands', `${skillName}.toml`),
    toml + '\n',
    canonicalRel,
    'toml'
  );
}

// โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

console.log(`\n${DRY_RUN ? '(DRY RUN) ' : ''}Syncing skills from tooling/skills/...\n`);

if (!existsSync(SKILLS_DIR)) {
  console.error('No tooling/skills/ directory found.');
  process.exit(1);
}

const skillDirs = readdirSync(SKILLS_DIR).filter(name =>
  statSync(join(SKILLS_DIR, name)).isDirectory()
);

for (const name of skillDirs) {
  syncSkill(name, join(SKILLS_DIR, name));
}

console.log(`\nโ”€โ”€ Summary โ”€โ”€`);
console.log(`  Written:  ${stats.written}`);
console.log(`  Skipped:  ${stats.skipped}`);
console.log(`  Warnings: ${stats.warnings}`);

if (stats.warnings > 0) {
  console.log('\n  โš   Some files were skipped โ€” see warnings above.');
}

console.log('');
  • [ ] Step 2: Run the script in dry-run mode to verify
bash
node tooling/scripts/sync-skills.js --dry-run

Expected: Lists all target files with (dry-run) would write prefix. Should show ~16 files (3 skill files + supporting files for each target + 2 command files).

  • [ ] Step 3: Commit
bash
git add tooling/scripts/sync-skills.js
git commit -m "feat: add skill sync script

Reads canonical skills from tooling/skills/ and distributes to
.github/skills/, .gemini/skills/, and .claude/skills/ with
platform-specific transformations. Generates command files for
skills with command: true. Includes dry-run mode and safety
checks (only overwrites files with AUTO-SYNCED comment)."

Task 3: Add npm Script and Run First Sync โ€‹

Wire up the npm script, run the actual sync, and verify all outputs match expectations.

Files:

  • Modify: package.json

  • [ ] Step 1: Add sync:skills to package.json

Add this line to the "scripts" section in package.json:

json
"sync:skills": "node tooling/scripts/sync-skills.js",

Place it near the other tooling scripts (after "storybook:tail" or similar).

  • [ ] Step 2: Run the sync
bash
npm run sync:skills

Expected: All files written successfully. The first run will warn about existing files in .github/skills/ and .gemini/skills/ that don't have the AUTO-SYNCED comment โ€” this is expected since they're the originals.

If warnings appear for existing files: These are the original (pre-sync) files that lack the auto-sync comment. Since we know they're identical to the canonical source, manually verify with diff and then overwrite them. The simplest approach: temporarily add the auto-sync comment to the existing files, or remove them and re-run.

bash
# Remove existing custom skill files (Nx plugin files are untouched since they're not in tooling/skills/)
rm .github/skills/pr-workbench/skill.md
rm .github/skills/monitor-ci/SKILL.md .github/skills/monitor-ci/scripts/*.mjs .github/skills/monitor-ci/references/fix-flows.md
rm .gemini/skills/pr-workbench/skill.md
rm .gemini/skills/monitor-ci/skill.md .gemini/skills/monitor-ci/scripts/*.mjs .gemini/skills/monitor-ci/references/fix-flows.md
rm .github/prompts/monitor-ci.prompt.md
rm .gemini/commands/monitor-ci.toml

# Re-run sync
npm run sync:skills

Expected: All files written with no warnings.

  • [ ] Step 3: Verify outputs are correct

Check key files have the auto-sync comment on line 1:

bash
head -1 .github/skills/pr-workbench/SKILL.md
head -1 .gemini/skills/pr-workbench/skill.md
head -1 .claude/skills/pr-workbench/SKILL.md
head -1 .github/prompts/monitor-ci.prompt.md
head -1 .gemini/commands/monitor-ci.toml

Each should show the appropriate AUTO-SYNCED comment.

Verify the .github/skills/ version uses SKILL.md uppercase:

bash
ls .github/skills/pr-workbench/

Expected: SKILL.md (not skill.md)

Verify .claude/skills/ was created:

bash
find .claude/skills -type f | sort

Expected:

.claude/skills/monitor-ci/SKILL.md
.claude/skills/monitor-ci/references/fix-flows.md
.claude/skills/monitor-ci/scripts/ci-poll-decide.mjs
.claude/skills/monitor-ci/scripts/ci-state-update.mjs
.claude/skills/pr-workbench/SKILL.md

Verify command content is correct โ€” check the GitHub prompt has ${input:args}:

bash
grep -c '${input:args}' .github/prompts/monitor-ci.prompt.md

Expected: at least 1 match.

Check the TOML has :

bash
grep -c '{{args}}' .gemini/commands/monitor-ci.toml

Expected: at least 1 match.

  • [ ] Step 4: Commit
bash
git add package.json .github/skills/ .gemini/skills/ .gemini/commands/ .github/prompts/ .claude/skills/
git commit -m "feat: run first skill sync, add sync:skills npm script

Replaces manually-maintained skill copies with auto-synced versions.
All custom skill files now have AUTO-SYNCED comment marking them as
generated. Creates .claude/skills/ directory (previously empty).
Nx plugin skills are untouched."

Task 4: Add Validation Check โ€‹

Add a sync-freshness check to the validate orchestrator so forgotten sync runs are caught before commit.

Files:

  • Create: tooling/scripts/lint.sync-skills.js

  • [ ] Step 1: Write the lint script

Create tooling/scripts/lint.sync-skills.js:

javascript
#!/usr/bin/env node

/**
 * Lint: Skill Sync Freshness
 *
 * Checks that synced skill files match what the sync script would generate.
 * Exits 0 if in sync, exits 1 if stale.
 *
 * This script runs the sync in dry-compare mode: it generates the expected
 * content in memory and compares against what's on disk.
 */

import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
import { join, dirname, extname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..', '..');
const SKILLS_DIR = join(ROOT, 'tooling', 'skills');

// โ”€โ”€ Reuse sync-skills helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

function syncComment(format, canonicalRelPath) {
  const prefix = `AUTO-SYNCED from ${canonicalRelPath} โ€” do not edit directly`;
  switch (format) {
    case 'md':   return `<!-- ${prefix} -->\n`;
    case 'toml': return `# ${prefix}\n`;
    case 'js':   return `// ${prefix}\n`;
    default:     return `<!-- ${prefix} -->\n`;
  }
}

function parseFrontmatter(content) {
  const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
  if (!match) return { meta: {}, body: content };
  const meta = {};
  for (const line of match[1].split('\n')) {
    const colonIdx = line.indexOf(':');
    if (colonIdx === -1) continue;
    const key = line.slice(0, colonIdx).trim();
    let val = line.slice(colonIdx + 1).trim();
    if ((val.startsWith("'") && val.endsWith("'")) || (val.startsWith('"') && val.endsWith('"'))) {
      val = val.slice(1, -1);
    }
    if (val === 'true') val = true;
    if (val === 'false') val = false;
    meta[key] = val;
  }
  return { meta, body: match[2] };
}

function formatForFile(filePath) {
  const ext = extname(filePath).toLowerCase();
  if (ext === '.toml') return 'toml';
  if (ext === '.js' || ext === '.mjs' || ext === '.cjs') return 'js';
  return 'md';
}

function collectSupportingFiles(skillDir) {
  const files = [];
  function walk(dir, relBase) {
    for (const entry of readdirSync(dir)) {
      const full = join(dir, entry);
      const rel = join(relBase, entry);
      if (statSync(full).isDirectory()) {
        walk(full, rel);
      } else if (entry !== 'skill.md') {
        files.push({ fullPath: full, relPath: rel });
      }
    }
  }
  walk(skillDir, '');
  return files;
}

// โ”€โ”€ Compare logic โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

const stale = [];

function expectFile(targetPath, content, canonicalRelPath, format) {
  const comment = syncComment(format, canonicalRelPath);
  const expected = comment + content;

  if (!existsSync(targetPath)) {
    stale.push(`MISSING: ${targetPath}`);
    return;
  }

  const actual = readFileSync(targetPath, 'utf8');
  if (actual !== expected) {
    stale.push(`STALE: ${targetPath}`);
  }
}

// โ”€โ”€ Check each skill โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

if (!existsSync(SKILLS_DIR)) {
  console.log('No tooling/skills/ directory โ€” nothing to check.');
  process.exit(0);
}

const skillDirs = readdirSync(SKILLS_DIR).filter(name =>
  statSync(join(SKILLS_DIR, name)).isDirectory()
);

for (const name of skillDirs) {
  const skillDir = join(SKILLS_DIR, name);
  const skillFile = join(skillDir, 'skill.md');
  if (!existsSync(skillFile)) continue;

  const raw = readFileSync(skillFile, 'utf8');
  const { meta, body } = parseFrontmatter(raw);
  const canonicalRel = `tooling/skills/${name}/skill.md`;

  // Skill files
  expectFile(join(ROOT, '.github', 'skills', name, 'SKILL.md'), raw, canonicalRel, 'md');
  expectFile(join(ROOT, '.gemini', 'skills', name, 'skill.md'), raw, canonicalRel, 'md');
  expectFile(join(ROOT, '.claude', 'skills', name, 'SKILL.md'), raw, canonicalRel, 'md');

  // Supporting files
  const supportingFiles = collectSupportingFiles(skillDir);
  for (const { fullPath, relPath } of supportingFiles) {
    const content = readFileSync(fullPath, 'utf8');
    const supportRel = `tooling/skills/${name}/${relPath}`;
    const fmt = formatForFile(fullPath);
    expectFile(join(ROOT, '.github', 'skills', name, relPath), content, supportRel, fmt);
    expectFile(join(ROOT, '.gemini', 'skills', name, relPath), content, supportRel, fmt);
    expectFile(join(ROOT, '.claude', 'skills', name, relPath), content, supportRel, fmt);
  }

  // Command files
  if (meta.command) {
    const ghBody = body.replace(/\$ARGUMENTS/g, '${input:args}');
    let ghFrontmatter = `---\ndescription: ${meta.description}`;
    if (meta['argument-hint']) {
      ghFrontmatter += `\nargument-hint: '${meta['argument-hint']}'`;
    }
    ghFrontmatter += `\n---\n`;
    expectFile(join(ROOT, '.github', 'prompts', `${name}.prompt.md`), ghFrontmatter + ghBody, canonicalRel, 'md');

    const geminiBody = body.replace(/\$ARGUMENTS/g, '{{args}}');
    const escapedDesc = meta.description.replace(/"/g, '\\"');
    const escapedBody = geminiBody.replace(/\\/g, '\\\\');
    const toml = `description = "${escapedDesc}"\nprompt = """\n${escapedBody}"""`;
    expectFile(join(ROOT, '.gemini', 'commands', `${name}.toml`), toml + '\n', canonicalRel, 'toml');
  }
}

// โ”€โ”€ Report โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

if (stale.length === 0) {
  console.log('Skills are in sync.');
  process.exit(0);
} else {
  console.error(`Skill sync is stale โ€” ${stale.length} file(s) out of date:\n`);
  for (const msg of stale) {
    console.error(`  ${msg}`);
  }
  console.error('\nRun `npm run sync:skills` to fix.');
  process.exit(1);
}
  • [ ] Step 2: Run the lint to verify it passes
bash
node tooling/scripts/lint.sync-skills.js

Expected: Skills are in sync. and exit code 0.

  • [ ] Step 3: Verify it catches staleness

Temporarily edit a synced file to break sync, then check:

bash
echo "# broken" >> .claude/skills/pr-workbench/SKILL.md
node tooling/scripts/lint.sync-skills.js
echo $?

Expected: Reports STALE: .claude/skills/pr-workbench/SKILL.md and exits 1.

Restore:

bash
npm run sync:skills
  • [ ] Step 4: Register the lint check in validate.js

Read tooling/scripts/validate.js to find where checks are registered, then add the sync-skills check. The validate orchestrator uses a checks array. Add:

javascript
{ label: 'Skill Sync', cmd: 'node', args: ['tooling/scripts/lint.sync-skills.js'], scope: 'lint', workspace: 'global' },

Add it to the checks array alongside the other global lint checks.

  • [ ] Step 5: Run validate with scope=lint to verify
bash
npm run validate -- --scope lint

Expected: Skill Sync check appears and passes.

  • [ ] Step 6: Commit
bash
git add tooling/scripts/lint.sync-skills.js tooling/scripts/validate.js
git commit -m "feat: add skill sync freshness lint check

Validates that synced skill files match what sync-skills.js would
generate. Integrated into npm run validate as a global lint check."

Task 5: End-to-End Verification โ€‹

Verify the complete workflow: edit a canonical skill, sync, and confirm all targets update.

  • [ ] Step 1: Make a trivial edit to the canonical pr-workbench skill

Edit tooling/skills/pr-workbench/skill.md โ€” add a blank comment line at the end:

markdown
<!-- end of skill -->
  • [ ] Step 2: Verify lint catches the drift
bash
node tooling/scripts/lint.sync-skills.js
echo $?

Expected: Reports 3 stale files (.github, .gemini, .claude copies of pr-workbench) and exits 1.

  • [ ] Step 3: Sync and verify
bash
npm run sync:skills
node tooling/scripts/lint.sync-skills.js
echo $?

Expected: Sync writes 3 files, lint passes (exit 0).

  • [ ] Step 4: Verify the edit propagated
bash
tail -1 .github/skills/pr-workbench/SKILL.md
tail -1 .gemini/skills/pr-workbench/skill.md
tail -1 .claude/skills/pr-workbench/SKILL.md

Expected: All three show <!-- end of skill -->.

  • [ ] Step 5: Revert the test edit
bash
git checkout -- tooling/skills/pr-workbench/skill.md
npm run sync:skills
  • [ ] Step 6: Run full validate
bash
npm run validate

Expected: All checks pass, including the new Skill Sync check.

  • [ ] Step 7: Commit if any files changed from the sync

If the revert + sync left files clean, no commit needed. If not:

bash
git add -A && git commit -m "chore: verify skill sync end-to-end"

Built with VitePress