diff --git a/.agents/skills/checkpoint/SKILL.md b/.agents/skills/checkpoint/SKILL.md index cab107f..9864762 100644 --- a/.agents/skills/checkpoint/SKILL.md +++ b/.agents/skills/checkpoint/SKILL.md @@ -90,4 +90,4 @@ Say: - Never apply labels or modify issues. - Never run `make check` — checkpoints are not quality gates. - Do not prompt for confirmation — `/checkpoint` is a quick save, not a ceremony. - + diff --git a/.agents/skills/deploy/SKILL.md b/.agents/skills/deploy/SKILL.md index 5ae2bbb..48dadbe 100644 --- a/.agents/skills/deploy/SKILL.md +++ b/.agents/skills/deploy/SKILL.md @@ -253,4 +253,4 @@ After the command runs, note the release URL from the output. Tell the user: > "Released vX.Y.Z. Issues #N, #M closed automatically. GitHub Release vX.Y.Z created at ``. Run `make deploy-prod` to ship to production." - + diff --git a/.agents/skills/qa/SKILL.md b/.agents/skills/qa/SKILL.md index 3caff8a..143b1b9 100644 --- a/.agents/skills/qa/SKILL.md +++ b/.agents/skills/qa/SKILL.md @@ -141,4 +141,4 @@ After applying labels, tell the user what was done: - Never post the comment without showing it to the user first. - Never apply labels without user confirmation (the confirmation in Step 3 is the single gate for both posting the comment and applying labels). - Never merge, deploy, or take any action beyond labeling and commenting. - + diff --git a/.agents/skills/review/SKILL.md b/.agents/skills/review/SKILL.md index 3b05b77..0e80566 100644 --- a/.agents/skills/review/SKILL.md +++ b/.agents/skills/review/SKILL.md @@ -17,7 +17,7 @@ description: Code Cannon: Run a standalone code review on a pull request If `ai` is `"off"`, say: -> "Code review is disabled for this project (REVIEW_GATE is set to 'off' in .codecannon.yaml). To enable reviews, set REVIEW_GATE to 'ai' or 'advisory' and re-run CodeCannon/sync.sh." +> "Code review is disabled for this project (REVIEW_GATE is set to 'off' in .codecannon.yaml). To enable reviews, set REVIEW_GATE to 'ai' or 'advisory' and re-run CodeCannon/sync.py." Do not proceed. @@ -65,4 +65,4 @@ After the review agent completes, relay its verdict to the user: - This command does not commit, push, or merge anything. It only reviews. - It may be called manually at any time, not just from `/submit-for-review`. - The review comment is posted to GitHub for the audit trail. - + diff --git a/.agents/skills/setup/SKILL.md b/.agents/skills/setup/SKILL.md index db355b6..f86ffc7 100644 --- a/.agents/skills/setup/SKILL.md +++ b/.agents/skills/setup/SKILL.md @@ -14,7 +14,7 @@ Before taking any action, determine which state the project is in. Run these che **Check A — Is this the Code Cannon skill library repo itself?** ```bash -test -f sync.sh && test -d skills +test -f sync.py && test -d skills ``` If both exist at the working directory root → **go to State 1**. @@ -52,7 +52,7 @@ Offer two forward paths and ask which they want: Explain the three-layer model: - **Skills** (`skills/*.md`) — portable workflow instructions with `main`-style tokens for project-specific values (see `config.schema.yaml`) - **Config** (`.codecannon.yaml`) — a project's values that fill those tokens at sync time -- **Sync** (`sync.sh`) — reads the config, substitutes values, and writes generated command files for each adapter (Claude Code → `.claude/commands/`, Cursor → `.cursor/rules/`) +- **Sync** (`sync.py`) — reads the config, substitutes values, and writes generated command files for each adapter (Claude Code → `.claude/commands/`, Cursor → `.cursor/rules/`) Point to README.md for the full skill list and documentation. Do not touch any file. @@ -66,7 +66,7 @@ git submodule add https://github.com/LightbridgeLab/CodeCannon.git CodeCannon git submodule update --init cp CodeCannon/templates/codecannon.yaml .codecannon.yaml # Edit .codecannon.yaml — set branch names, commands, adapters -CodeCannon/sync.sh +CodeCannon/sync.py ``` Do not touch any file. The user runs these commands in their project directory. @@ -77,10 +77,10 @@ Do not touch any file. The user runs these commands in their project directory. Run checks 1–7 in order. Stop at the first failing check and address it. After describing the fix, tell the user to run `/setup` again once they've resolved it. Do not continue past a failing check. -### Check 1 — CodeCannon/sync.sh present +### Check 1 — CodeCannon/sync.py present ```bash -test -f CodeCannon/sync.sh +test -f CodeCannon/sync.py ``` If missing: the submodule was added to `.gitmodules` or `CodeCannon/` exists as an empty directory, but it hasn't been initialized. Show: @@ -191,18 +191,18 @@ If anything is flagged: show the specific key names and what they should likely ### Check 7 — Generated skill output present -Check whether sync.sh has been run by looking for any of the adapter output directories configured in `.codecannon.yaml`: +Check whether sync.py has been run by looking for any of the adapter output directories configured in `.codecannon.yaml`: ```bash test -d .claude/commands || test -d .cursor/rules || test -d .agents/skills || test -d .gemini/skills ``` -If none exist: "sync.sh hasn't been run yet — the skill commands don't exist." +If none exist: "sync.py hasn't been run yet — the skill commands don't exist." Show: ```bash -CodeCannon/sync.sh +CodeCannon/sync.py ``` Ask permission to run it. If the user agrees, run it. If they decline, tell them to run it manually before continuing. Stop. @@ -364,11 +364,11 @@ Add a note: `/start` can be used to create well-formed GitHub issues without wri ## Hard rules -- Only modify `.codecannon.yaml`. Do not touch any other file (except running `CodeCannon/sync.sh`, which modifies `.claude/commands/` — permitted only with explicit user approval). -- Do not run `sync.sh` without explicit user permission. +- Only modify `.codecannon.yaml`. Do not touch any other file (except running `CodeCannon/sync.py`, which modifies `.claude/commands/` — permitted only with explicit user approval). +- Do not run `sync.py` without explicit user permission. - Do not create `.codecannon.yaml` without explicit user permission. - Do not report a configuration problem unless confident the condition is genuinely broken. Prefer false negatives over false positives on all diagnostic checks. - Never fetch more than 100 labels in a single command. `gh label list --limit 100` is the ceiling. - Do not skip any human gate in Phase 2 or Phase 3 — each write requires confirmation. - If the user skips a config value, do not ask again. Move on. - + diff --git a/.agents/skills/start/SKILL.md b/.agents/skills/start/SKILL.md index 27c02ed..28e63a4 100644 --- a/.agents/skills/start/SKILL.md +++ b/.agents/skills/start/SKILL.md @@ -313,4 +313,4 @@ When done, say: **"The code is ready for review. Please run `make dev` and test - The issue is assigned to `@me` at creation. If you are creating a ticket on someone else's behalf, remove the assignee after creation with `gh issue edit --remove-assignee @me`. - Apply resolved labels and milestone to every new issue. Label resolution order: per-invocation flag → pool selection from `bug, documentation, enhancement, chore` → omit `--label` entirely. Never apply a label outside `bug, documentation, enhancement, chore`. - Milestone resolution order: per-invocation flag → auto-detected from GitHub open milestones. Never prompt for a milestone more than once per invocation. - + diff --git a/.agents/skills/status/SKILL.md b/.agents/skills/status/SKILL.md index 0c97559..6f0f98b 100644 --- a/.agents/skills/status/SKILL.md +++ b/.agents/skills/status/SKILL.md @@ -366,4 +366,4 @@ Do not post, comment, write files, or take any action. Output only. - If `gh` is unauthenticated or any fetch fails, report the error and stop immediately. - Do not retry failed commands. - Strip the leading `@` from the subject when passing to `gh` flags that do not accept it. - + diff --git a/.agents/skills/submit-for-review/SKILL.md b/.agents/skills/submit-for-review/SKILL.md index 5cab5f5..f031964 100644 --- a/.agents/skills/submit-for-review/SKILL.md +++ b/.agents/skills/submit-for-review/SKILL.md @@ -325,4 +325,4 @@ If a single `gh issue create` call fails, report the failure for that finding an - `/submit-for-review` merges only to `dev` — never directly to `main`. - If `make merge` fails for any reason, report it and stop — do not attempt workarounds. - The follow-up issue offer in Step 9 runs only after a successful merge and only when the review produced non-blocking findings. Never prompt the user for follow-ups when the review blocked the merge — those findings should be fixed, not ticketed. - + diff --git a/.claude/commands/checkpoint.md b/.claude/commands/checkpoint.md index c7e8473..cb398f5 100644 --- a/.claude/commands/checkpoint.md +++ b/.claude/commands/checkpoint.md @@ -85,4 +85,4 @@ Say: - Never apply labels or modify issues. - Never run `make check` — checkpoints are not quality gates. - Do not prompt for confirmation — `/checkpoint` is a quick save, not a ceremony. - + diff --git a/.claude/commands/deploy.md b/.claude/commands/deploy.md index 9a5a2cb..54a296d 100644 --- a/.claude/commands/deploy.md +++ b/.claude/commands/deploy.md @@ -248,4 +248,4 @@ After the command runs, note the release URL from the output. Tell the user: > "Released vX.Y.Z. Issues #N, #M closed automatically. GitHub Release vX.Y.Z created at ``. Run `make deploy-prod` to ship to production." - + diff --git a/.claude/commands/qa.md b/.claude/commands/qa.md index 7a09d4d..d10b822 100644 --- a/.claude/commands/qa.md +++ b/.claude/commands/qa.md @@ -136,4 +136,4 @@ After applying labels, tell the user what was done: - Never post the comment without showing it to the user first. - Never apply labels without user confirmation (the confirmation in Step 3 is the single gate for both posting the comment and applying labels). - Never merge, deploy, or take any action beyond labeling and commenting. - + diff --git a/.claude/commands/review.md b/.claude/commands/review.md index 6be83ca..9f544e3 100644 --- a/.claude/commands/review.md +++ b/.claude/commands/review.md @@ -12,7 +12,7 @@ Code Cannon: Run a standalone code review on a pull request If `ai` is `"off"`, say: -> "Code review is disabled for this project (REVIEW_GATE is set to 'off' in .codecannon.yaml). To enable reviews, set REVIEW_GATE to 'ai' or 'advisory' and re-run CodeCannon/sync.sh." +> "Code review is disabled for this project (REVIEW_GATE is set to 'off' in .codecannon.yaml). To enable reviews, set REVIEW_GATE to 'ai' or 'advisory' and re-run CodeCannon/sync.py." Do not proceed. @@ -60,4 +60,4 @@ After the review agent completes, relay its verdict to the user: - This command does not commit, push, or merge anything. It only reviews. - It may be called manually at any time, not just from `/submit-for-review`. - The review comment is posted to GitHub for the audit trail. - + diff --git a/.claude/commands/setup.md b/.claude/commands/setup.md index 299789b..15d78d5 100644 --- a/.claude/commands/setup.md +++ b/.claude/commands/setup.md @@ -9,7 +9,7 @@ Before taking any action, determine which state the project is in. Run these che **Check A — Is this the Code Cannon skill library repo itself?** ```bash -test -f sync.sh && test -d skills +test -f sync.py && test -d skills ``` If both exist at the working directory root → **go to State 1**. @@ -47,7 +47,7 @@ Offer two forward paths and ask which they want: Explain the three-layer model: - **Skills** (`skills/*.md`) — portable workflow instructions with `main`-style tokens for project-specific values (see `config.schema.yaml`) - **Config** (`.codecannon.yaml`) — a project's values that fill those tokens at sync time -- **Sync** (`sync.sh`) — reads the config, substitutes values, and writes generated command files for each adapter (Claude Code → `.claude/commands/`, Cursor → `.cursor/rules/`) +- **Sync** (`sync.py`) — reads the config, substitutes values, and writes generated command files for each adapter (Claude Code → `.claude/commands/`, Cursor → `.cursor/rules/`) Point to README.md for the full skill list and documentation. Do not touch any file. @@ -61,7 +61,7 @@ git submodule add https://github.com/LightbridgeLab/CodeCannon.git CodeCannon git submodule update --init cp CodeCannon/templates/codecannon.yaml .codecannon.yaml # Edit .codecannon.yaml — set branch names, commands, adapters -CodeCannon/sync.sh +CodeCannon/sync.py ``` Do not touch any file. The user runs these commands in their project directory. @@ -72,10 +72,10 @@ Do not touch any file. The user runs these commands in their project directory. Run checks 1–7 in order. Stop at the first failing check and address it. After describing the fix, tell the user to run `/setup` again once they've resolved it. Do not continue past a failing check. -### Check 1 — CodeCannon/sync.sh present +### Check 1 — CodeCannon/sync.py present ```bash -test -f CodeCannon/sync.sh +test -f CodeCannon/sync.py ``` If missing: the submodule was added to `.gitmodules` or `CodeCannon/` exists as an empty directory, but it hasn't been initialized. Show: @@ -186,18 +186,18 @@ If anything is flagged: show the specific key names and what they should likely ### Check 7 — Generated skill output present -Check whether sync.sh has been run by looking for any of the adapter output directories configured in `.codecannon.yaml`: +Check whether sync.py has been run by looking for any of the adapter output directories configured in `.codecannon.yaml`: ```bash test -d .claude/commands || test -d .cursor/rules || test -d .agents/skills || test -d .gemini/skills ``` -If none exist: "sync.sh hasn't been run yet — the skill commands don't exist." +If none exist: "sync.py hasn't been run yet — the skill commands don't exist." Show: ```bash -CodeCannon/sync.sh +CodeCannon/sync.py ``` Ask permission to run it. If the user agrees, run it. If they decline, tell them to run it manually before continuing. Stop. @@ -359,11 +359,11 @@ Add a note: `/start` can be used to create well-formed GitHub issues without wri ## Hard rules -- Only modify `.codecannon.yaml`. Do not touch any other file (except running `CodeCannon/sync.sh`, which modifies `.claude/commands/` — permitted only with explicit user approval). -- Do not run `sync.sh` without explicit user permission. +- Only modify `.codecannon.yaml`. Do not touch any other file (except running `CodeCannon/sync.py`, which modifies `.claude/commands/` — permitted only with explicit user approval). +- Do not run `sync.py` without explicit user permission. - Do not create `.codecannon.yaml` without explicit user permission. - Do not report a configuration problem unless confident the condition is genuinely broken. Prefer false negatives over false positives on all diagnostic checks. - Never fetch more than 100 labels in a single command. `gh label list --limit 100` is the ceiling. - Do not skip any human gate in Phase 2 or Phase 3 — each write requires confirmation. - If the user skips a config value, do not ask again. Move on. - + diff --git a/.claude/commands/start.md b/.claude/commands/start.md index b769146..dc01d24 100644 --- a/.claude/commands/start.md +++ b/.claude/commands/start.md @@ -308,4 +308,4 @@ When done, say: **"The code is ready for review. Please run `make dev` and test - The issue is assigned to `@me` at creation. If you are creating a ticket on someone else's behalf, remove the assignee after creation with `gh issue edit --remove-assignee @me`. - Apply resolved labels and milestone to every new issue. Label resolution order: per-invocation flag → pool selection from `bug, documentation, enhancement, chore` → omit `--label` entirely. Never apply a label outside `bug, documentation, enhancement, chore`. - Milestone resolution order: per-invocation flag → auto-detected from GitHub open milestones. Never prompt for a milestone more than once per invocation. - + diff --git a/.claude/commands/status.md b/.claude/commands/status.md index f7d3554..6fb7c49 100644 --- a/.claude/commands/status.md +++ b/.claude/commands/status.md @@ -361,4 +361,4 @@ Do not post, comment, write files, or take any action. Output only. - If `gh` is unauthenticated or any fetch fails, report the error and stop immediately. - Do not retry failed commands. - Strip the leading `@` from the subject when passing to `gh` flags that do not accept it. - + diff --git a/.claude/commands/submit-for-review.md b/.claude/commands/submit-for-review.md index 6ae24c6..cb9f0ce 100644 --- a/.claude/commands/submit-for-review.md +++ b/.claude/commands/submit-for-review.md @@ -320,4 +320,4 @@ If a single `gh issue create` call fails, report the failure for that finding an - `/submit-for-review` merges only to `dev` — never directly to `main`. - If `make merge` fails for any reason, report it and stop — do not attempt workarounds. - The follow-up issue offer in Step 9 runs only after a successful merge and only when the review produced non-blocking findings. Never prompt the user for follow-ups when the review blocked the merge — those findings should be fixed, not ticketed. - + diff --git a/.claude/review-agent-prompt.md b/.claude/review-agent-prompt.md index 8c167c1..1f2b743 100644 --- a/.claude/review-agent-prompt.md +++ b/.claude/review-agent-prompt.md @@ -107,4 +107,4 @@ If no findings: "No issues found. Code looks correct and follows project convent - Do not suggest improvements outside the scope of the PR. - Do not re-review files that haven't changed. - Your review is posted to GitHub for the audit trail — keep it professional. - + diff --git a/.claude/settings.local.json.example b/.claude/settings.local.json.example index dfb9d30..4e6cacb 100644 --- a/.claude/settings.local.json.example +++ b/.claude/settings.local.json.example @@ -10,7 +10,7 @@ "Bash(python3:*)", "Bash(which:*)", "Bash(wc:*)", - "Bash(./sync.sh:*)", + "Bash(./sync.py:*)", "WebSearch" ] } diff --git a/.codecannon.yaml b/.codecannon.yaml index bfed680..ac659f4 100644 --- a/.codecannon.yaml +++ b/.codecannon.yaml @@ -1,7 +1,7 @@ # .codecannon.yaml — project configuration for Code Cannon skill sync # -# This is Code Cannon's own config. Unlike consumer projects (which run CodeCannon/sync.sh), -# this repo runs sync as ./sync.sh — Code Cannon is not a submodule of itself. +# This is Code Cannon's own config. Unlike consumer projects (which run CodeCannon/sync.py), +# this repo runs sync as ./sync.py — Code Cannon is not a submodule of itself. # # See config.schema.yaml for documentation on each placeholder. @@ -14,7 +14,7 @@ adapters: - gemini # Placeholder values substituted into skill files during sync. -# Remove any you don't need; sync.sh will warn about unresolved placeholders. +# Remove any you don't need; sync.py will warn about unresolved placeholders. config: BRANCH_PROD: main BRANCH_DEV: dev diff --git a/.cursor/rules/checkpoint.mdc b/.cursor/rules/checkpoint.mdc index 5ec2e26..fede9c4 100644 --- a/.cursor/rules/checkpoint.mdc +++ b/.cursor/rules/checkpoint.mdc @@ -91,4 +91,4 @@ Say: - Never apply labels or modify issues. - Never run `make check` — checkpoints are not quality gates. - Do not prompt for confirmation — `/checkpoint` is a quick save, not a ceremony. - + diff --git a/.cursor/rules/deploy.mdc b/.cursor/rules/deploy.mdc index 664a54a..4bfab50 100644 --- a/.cursor/rules/deploy.mdc +++ b/.cursor/rules/deploy.mdc @@ -254,4 +254,4 @@ After the command runs, note the release URL from the output. Tell the user: > "Released vX.Y.Z. Issues #N, #M closed automatically. GitHub Release vX.Y.Z created at ``. Run `make deploy-prod` to ship to production." - + diff --git a/.cursor/rules/qa.mdc b/.cursor/rules/qa.mdc index d5e2f97..f7cbb47 100644 --- a/.cursor/rules/qa.mdc +++ b/.cursor/rules/qa.mdc @@ -142,4 +142,4 @@ After applying labels, tell the user what was done: - Never post the comment without showing it to the user first. - Never apply labels without user confirmation (the confirmation in Step 3 is the single gate for both posting the comment and applying labels). - Never merge, deploy, or take any action beyond labeling and commenting. - + diff --git a/.cursor/rules/review.mdc b/.cursor/rules/review.mdc index 76f26ec..aa42ebb 100644 --- a/.cursor/rules/review.mdc +++ b/.cursor/rules/review.mdc @@ -18,7 +18,7 @@ alwaysApply: false If `ai` is `"off"`, say: -> "Code review is disabled for this project (REVIEW_GATE is set to 'off' in .codecannon.yaml). To enable reviews, set REVIEW_GATE to 'ai' or 'advisory' and re-run CodeCannon/sync.sh." +> "Code review is disabled for this project (REVIEW_GATE is set to 'off' in .codecannon.yaml). To enable reviews, set REVIEW_GATE to 'ai' or 'advisory' and re-run CodeCannon/sync.py." Do not proceed. @@ -66,4 +66,4 @@ After the review agent completes, relay its verdict to the user: - This command does not commit, push, or merge anything. It only reviews. - It may be called manually at any time, not just from `/submit-for-review`. - The review comment is posted to GitHub for the audit trail. - + diff --git a/.cursor/rules/setup.mdc b/.cursor/rules/setup.mdc index b8e89a5..689ece5 100644 --- a/.cursor/rules/setup.mdc +++ b/.cursor/rules/setup.mdc @@ -15,7 +15,7 @@ Before taking any action, determine which state the project is in. Run these che **Check A — Is this the Code Cannon skill library repo itself?** ```bash -test -f sync.sh && test -d skills +test -f sync.py && test -d skills ``` If both exist at the working directory root → **go to State 1**. @@ -53,7 +53,7 @@ Offer two forward paths and ask which they want: Explain the three-layer model: - **Skills** (`skills/*.md`) — portable workflow instructions with `main`-style tokens for project-specific values (see `config.schema.yaml`) - **Config** (`.codecannon.yaml`) — a project's values that fill those tokens at sync time -- **Sync** (`sync.sh`) — reads the config, substitutes values, and writes generated command files for each adapter (Claude Code → `.claude/commands/`, Cursor → `.cursor/rules/`) +- **Sync** (`sync.py`) — reads the config, substitutes values, and writes generated command files for each adapter (Claude Code → `.claude/commands/`, Cursor → `.cursor/rules/`) Point to README.md for the full skill list and documentation. Do not touch any file. @@ -67,7 +67,7 @@ git submodule add https://github.com/LightbridgeLab/CodeCannon.git CodeCannon git submodule update --init cp CodeCannon/templates/codecannon.yaml .codecannon.yaml # Edit .codecannon.yaml — set branch names, commands, adapters -CodeCannon/sync.sh +CodeCannon/sync.py ``` Do not touch any file. The user runs these commands in their project directory. @@ -78,10 +78,10 @@ Do not touch any file. The user runs these commands in their project directory. Run checks 1–7 in order. Stop at the first failing check and address it. After describing the fix, tell the user to run `/setup` again once they've resolved it. Do not continue past a failing check. -### Check 1 — CodeCannon/sync.sh present +### Check 1 — CodeCannon/sync.py present ```bash -test -f CodeCannon/sync.sh +test -f CodeCannon/sync.py ``` If missing: the submodule was added to `.gitmodules` or `CodeCannon/` exists as an empty directory, but it hasn't been initialized. Show: @@ -192,18 +192,18 @@ If anything is flagged: show the specific key names and what they should likely ### Check 7 — Generated skill output present -Check whether sync.sh has been run by looking for any of the adapter output directories configured in `.codecannon.yaml`: +Check whether sync.py has been run by looking for any of the adapter output directories configured in `.codecannon.yaml`: ```bash test -d .claude/commands || test -d .cursor/rules || test -d .agents/skills || test -d .gemini/skills ``` -If none exist: "sync.sh hasn't been run yet — the skill commands don't exist." +If none exist: "sync.py hasn't been run yet — the skill commands don't exist." Show: ```bash -CodeCannon/sync.sh +CodeCannon/sync.py ``` Ask permission to run it. If the user agrees, run it. If they decline, tell them to run it manually before continuing. Stop. @@ -365,11 +365,11 @@ Add a note: `/start` can be used to create well-formed GitHub issues without wri ## Hard rules -- Only modify `.codecannon.yaml`. Do not touch any other file (except running `CodeCannon/sync.sh`, which modifies `.claude/commands/` — permitted only with explicit user approval). -- Do not run `sync.sh` without explicit user permission. +- Only modify `.codecannon.yaml`. Do not touch any other file (except running `CodeCannon/sync.py`, which modifies `.claude/commands/` — permitted only with explicit user approval). +- Do not run `sync.py` without explicit user permission. - Do not create `.codecannon.yaml` without explicit user permission. - Do not report a configuration problem unless confident the condition is genuinely broken. Prefer false negatives over false positives on all diagnostic checks. - Never fetch more than 100 labels in a single command. `gh label list --limit 100` is the ceiling. - Do not skip any human gate in Phase 2 or Phase 3 — each write requires confirmation. - If the user skips a config value, do not ask again. Move on. - + diff --git a/.cursor/rules/start.mdc b/.cursor/rules/start.mdc index 58440b6..720171b 100644 --- a/.cursor/rules/start.mdc +++ b/.cursor/rules/start.mdc @@ -314,4 +314,4 @@ When done, say: **"The code is ready for review. Please run `make dev` and test - The issue is assigned to `@me` at creation. If you are creating a ticket on someone else's behalf, remove the assignee after creation with `gh issue edit --remove-assignee @me`. - Apply resolved labels and milestone to every new issue. Label resolution order: per-invocation flag → pool selection from `bug, documentation, enhancement, chore` → omit `--label` entirely. Never apply a label outside `bug, documentation, enhancement, chore`. - Milestone resolution order: per-invocation flag → auto-detected from GitHub open milestones. Never prompt for a milestone more than once per invocation. - + diff --git a/.cursor/rules/status.mdc b/.cursor/rules/status.mdc index 373745b..38287b2 100644 --- a/.cursor/rules/status.mdc +++ b/.cursor/rules/status.mdc @@ -367,4 +367,4 @@ Do not post, comment, write files, or take any action. Output only. - If `gh` is unauthenticated or any fetch fails, report the error and stop immediately. - Do not retry failed commands. - Strip the leading `@` from the subject when passing to `gh` flags that do not accept it. - + diff --git a/.cursor/rules/submit-for-review.mdc b/.cursor/rules/submit-for-review.mdc index 085ebad..8fa4491 100644 --- a/.cursor/rules/submit-for-review.mdc +++ b/.cursor/rules/submit-for-review.mdc @@ -326,4 +326,4 @@ If a single `gh issue create` call fails, report the failure for that finding an - `/submit-for-review` merges only to `dev` — never directly to `main`. - If `make merge` fails for any reason, report it and stop — do not attempt workarounds. - The follow-up issue offer in Step 9 runs only after a successful merge and only when the review produced non-blocking findings. Never prompt the user for follow-ups when the review blocked the merge — those findings should be fixed, not ticketed. - + diff --git a/.gemini/skills/checkpoint/SKILL.md b/.gemini/skills/checkpoint/SKILL.md index beb2bf8..1f2c0ff 100644 --- a/.gemini/skills/checkpoint/SKILL.md +++ b/.gemini/skills/checkpoint/SKILL.md @@ -90,4 +90,4 @@ Say: - Never apply labels or modify issues. - Never run `make check` — checkpoints are not quality gates. - Do not prompt for confirmation — `/checkpoint` is a quick save, not a ceremony. - + diff --git a/.gemini/skills/deploy/SKILL.md b/.gemini/skills/deploy/SKILL.md index c50671f..0671f7f 100644 --- a/.gemini/skills/deploy/SKILL.md +++ b/.gemini/skills/deploy/SKILL.md @@ -253,4 +253,4 @@ After the command runs, note the release URL from the output. Tell the user: > "Released vX.Y.Z. Issues #N, #M closed automatically. GitHub Release vX.Y.Z created at ``. Run `make deploy-prod` to ship to production." - + diff --git a/.gemini/skills/qa/SKILL.md b/.gemini/skills/qa/SKILL.md index c0a1c01..8f4ce48 100644 --- a/.gemini/skills/qa/SKILL.md +++ b/.gemini/skills/qa/SKILL.md @@ -141,4 +141,4 @@ After applying labels, tell the user what was done: - Never post the comment without showing it to the user first. - Never apply labels without user confirmation (the confirmation in Step 3 is the single gate for both posting the comment and applying labels). - Never merge, deploy, or take any action beyond labeling and commenting. - + diff --git a/.gemini/skills/review/SKILL.md b/.gemini/skills/review/SKILL.md index 6ed1327..bf39c80 100644 --- a/.gemini/skills/review/SKILL.md +++ b/.gemini/skills/review/SKILL.md @@ -17,7 +17,7 @@ description: Code Cannon: Run a standalone code review on a pull request If `ai` is `"off"`, say: -> "Code review is disabled for this project (REVIEW_GATE is set to 'off' in .codecannon.yaml). To enable reviews, set REVIEW_GATE to 'ai' or 'advisory' and re-run CodeCannon/sync.sh." +> "Code review is disabled for this project (REVIEW_GATE is set to 'off' in .codecannon.yaml). To enable reviews, set REVIEW_GATE to 'ai' or 'advisory' and re-run CodeCannon/sync.py." Do not proceed. @@ -65,4 +65,4 @@ After the review agent completes, relay its verdict to the user: - This command does not commit, push, or merge anything. It only reviews. - It may be called manually at any time, not just from `/submit-for-review`. - The review comment is posted to GitHub for the audit trail. - + diff --git a/.gemini/skills/setup/SKILL.md b/.gemini/skills/setup/SKILL.md index 1c274f2..3ca9690 100644 --- a/.gemini/skills/setup/SKILL.md +++ b/.gemini/skills/setup/SKILL.md @@ -14,7 +14,7 @@ Before taking any action, determine which state the project is in. Run these che **Check A — Is this the Code Cannon skill library repo itself?** ```bash -test -f sync.sh && test -d skills +test -f sync.py && test -d skills ``` If both exist at the working directory root → **go to State 1**. @@ -52,7 +52,7 @@ Offer two forward paths and ask which they want: Explain the three-layer model: - **Skills** (`skills/*.md`) — portable workflow instructions with `main`-style tokens for project-specific values (see `config.schema.yaml`) - **Config** (`.codecannon.yaml`) — a project's values that fill those tokens at sync time -- **Sync** (`sync.sh`) — reads the config, substitutes values, and writes generated command files for each adapter (Claude Code → `.claude/commands/`, Cursor → `.cursor/rules/`) +- **Sync** (`sync.py`) — reads the config, substitutes values, and writes generated command files for each adapter (Claude Code → `.claude/commands/`, Cursor → `.cursor/rules/`) Point to README.md for the full skill list and documentation. Do not touch any file. @@ -66,7 +66,7 @@ git submodule add https://github.com/LightbridgeLab/CodeCannon.git CodeCannon git submodule update --init cp CodeCannon/templates/codecannon.yaml .codecannon.yaml # Edit .codecannon.yaml — set branch names, commands, adapters -CodeCannon/sync.sh +CodeCannon/sync.py ``` Do not touch any file. The user runs these commands in their project directory. @@ -77,10 +77,10 @@ Do not touch any file. The user runs these commands in their project directory. Run checks 1–7 in order. Stop at the first failing check and address it. After describing the fix, tell the user to run `/setup` again once they've resolved it. Do not continue past a failing check. -### Check 1 — CodeCannon/sync.sh present +### Check 1 — CodeCannon/sync.py present ```bash -test -f CodeCannon/sync.sh +test -f CodeCannon/sync.py ``` If missing: the submodule was added to `.gitmodules` or `CodeCannon/` exists as an empty directory, but it hasn't been initialized. Show: @@ -191,18 +191,18 @@ If anything is flagged: show the specific key names and what they should likely ### Check 7 — Generated skill output present -Check whether sync.sh has been run by looking for any of the adapter output directories configured in `.codecannon.yaml`: +Check whether sync.py has been run by looking for any of the adapter output directories configured in `.codecannon.yaml`: ```bash test -d .claude/commands || test -d .cursor/rules || test -d .agents/skills || test -d .gemini/skills ``` -If none exist: "sync.sh hasn't been run yet — the skill commands don't exist." +If none exist: "sync.py hasn't been run yet — the skill commands don't exist." Show: ```bash -CodeCannon/sync.sh +CodeCannon/sync.py ``` Ask permission to run it. If the user agrees, run it. If they decline, tell them to run it manually before continuing. Stop. @@ -364,11 +364,11 @@ Add a note: `/start` can be used to create well-formed GitHub issues without wri ## Hard rules -- Only modify `.codecannon.yaml`. Do not touch any other file (except running `CodeCannon/sync.sh`, which modifies `.claude/commands/` — permitted only with explicit user approval). -- Do not run `sync.sh` without explicit user permission. +- Only modify `.codecannon.yaml`. Do not touch any other file (except running `CodeCannon/sync.py`, which modifies `.claude/commands/` — permitted only with explicit user approval). +- Do not run `sync.py` without explicit user permission. - Do not create `.codecannon.yaml` without explicit user permission. - Do not report a configuration problem unless confident the condition is genuinely broken. Prefer false negatives over false positives on all diagnostic checks. - Never fetch more than 100 labels in a single command. `gh label list --limit 100` is the ceiling. - Do not skip any human gate in Phase 2 or Phase 3 — each write requires confirmation. - If the user skips a config value, do not ask again. Move on. - + diff --git a/.gemini/skills/start/SKILL.md b/.gemini/skills/start/SKILL.md index 640d717..a84528a 100644 --- a/.gemini/skills/start/SKILL.md +++ b/.gemini/skills/start/SKILL.md @@ -313,4 +313,4 @@ When done, say: **"The code is ready for review. Please run `make dev` and test - The issue is assigned to `@me` at creation. If you are creating a ticket on someone else's behalf, remove the assignee after creation with `gh issue edit --remove-assignee @me`. - Apply resolved labels and milestone to every new issue. Label resolution order: per-invocation flag → pool selection from `bug, documentation, enhancement, chore` → omit `--label` entirely. Never apply a label outside `bug, documentation, enhancement, chore`. - Milestone resolution order: per-invocation flag → auto-detected from GitHub open milestones. Never prompt for a milestone more than once per invocation. - + diff --git a/.gemini/skills/status/SKILL.md b/.gemini/skills/status/SKILL.md index 3b4fb5c..bd4caca 100644 --- a/.gemini/skills/status/SKILL.md +++ b/.gemini/skills/status/SKILL.md @@ -366,4 +366,4 @@ Do not post, comment, write files, or take any action. Output only. - If `gh` is unauthenticated or any fetch fails, report the error and stop immediately. - Do not retry failed commands. - Strip the leading `@` from the subject when passing to `gh` flags that do not accept it. - + diff --git a/.gemini/skills/submit-for-review/SKILL.md b/.gemini/skills/submit-for-review/SKILL.md index 5a58f16..c2bab6e 100644 --- a/.gemini/skills/submit-for-review/SKILL.md +++ b/.gemini/skills/submit-for-review/SKILL.md @@ -325,4 +325,4 @@ If a single `gh issue create` call fails, report the failure for that finding an - `/submit-for-review` merges only to `dev` — never directly to `main`. - If `make merge` fails for any reason, report it and stop — do not attempt workarounds. - The follow-up issue offer in Step 9 runs only after a successful merge and only when the review produced non-blocking findings. Never prompt the user for follow-ups when the review blocked the merge — those findings should be fixed, not ticketed. - + diff --git a/.github/workflows/sync-check.yml b/.github/workflows/sync-check.yml index 171ea5a..9eebc17 100644 --- a/.github/workflows/sync-check.yml +++ b/.github/workflows/sync-check.yml @@ -7,5 +7,5 @@ jobs: - uses: actions/checkout@v5 - name: Check for sync drift run: | - ./sync.sh --dry-run + ./sync.py --dry-run # exits non-zero if any files would be written diff --git a/.github/workflows/sync-dev.yml b/.github/workflows/sync-dev.yml new file mode 100644 index 0000000..5b73771 --- /dev/null +++ b/.github/workflows/sync-dev.yml @@ -0,0 +1,38 @@ +name: Sync dev with main + +on: + push: + branches: [main] + +permissions: + contents: write + issues: write + +jobs: + sync-dev: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Fast-forward dev to main + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git checkout dev + if git merge --ff-only origin/main; then + git push origin dev + echo "✅ dev fast-forwarded to match main" + else + echo "❌ Fast-forward failed — dev has diverged from main" + gh issue create \ + --title "Dev branch has diverged from main and needs manual sync" \ + --label "bug" \ + --body "The automatic fast-forward of \`dev\` to \`main\` failed. This means \`dev\` has commits that are not on \`main\`, preventing an automatic sync.\n\nPlease manually reconcile the branches:\n\`\`\`bash\ngit checkout dev\ngit merge main\n\`\`\`\n\nTriggered by push to main: ${{ github.sha }}" + exit 1 + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..63174e3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: Tests +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.12"] + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Run tests + run: python3 -m unittest discover -s tests -v + - name: Install coverage + if: matrix.python-version == '3.12' + run: pip install coverage + - name: Run tests with coverage + if: matrix.python-version == '3.12' + run: | + python3 -m coverage run -m unittest discover -s tests + python3 -m coverage report --show-missing + python3 -m coverage xml -o coverage.xml + - name: Upload coverage to Codecov + if: matrix.python-version == '3.12' && github.event_name == 'push' + uses: codecov/codecov-action@v5 + with: + files: coverage.xml + fail_ci_if_not_uploaded: false diff --git a/.gitignore b/.gitignore index d411e17..cb2d4a2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ *.swo *~ +# Python +__pycache__/ +*.pyc + # Environment .env .env.local diff --git a/AGENTS.md b/AGENTS.md index d38f85d..8fa85f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ Instructions for AI coding agents working on the CodeCannon project itself. -CodeCannon is a skill distribution system. It stores generic agent workflow skills in `skills/`, adapter configs in `adapters/`, and a `sync.sh` script that generates agent-specific skill files for downstream projects. One source of truth, synced to every project and every AI agent. +CodeCannon is a skill distribution system. It stores generic agent workflow skills in `skills/`, adapter configs in `adapters/`, and a `sync.py` script that generates agent-specific skill files for downstream projects. One source of truth, synced to every project and every AI agent. ## Branch strategy @@ -35,7 +35,7 @@ These are the single source of truth for all generated output. Every edit to age .gemini/skills/ Gemini agent skills ``` -These are regenerated by `./sync.sh`. Each file contains a hash marker for change detection. If you need to change agent output, edit the source skill in `skills/` and re-run sync. +These are regenerated by `./sync.py`. Each file contains a hash marker for change detection. If you need to change agent output, edit the source skill in `skills/` and re-run sync. ### Adapters @@ -75,7 +75,7 @@ When asked to update documentation, check `docs/` first — most detailed docs l ``` Makefile CodeCannon-specific targets (sync, versioning, deployment) Makefile.agents.mk Generic git workflow targets (branch, pr, abandon, merge, promote) -sync.sh The core sync engine (Python 3) +sync.py The core sync engine (Python 3) VERSION Current version number (plain text, e.g. 0.3.3) ``` @@ -130,13 +130,13 @@ Skills support conditional rendering: ### Change detection -`sync.sh` embeds an MD5 hash in a marker comment at the end of each generated file: +`sync.py` embeds an MD5 hash in a marker comment at the end of each generated file: ``` - + ``` -If someone manually edits a generated file, the hash won't match and `sync.sh` will refuse to overwrite it (unless run with `--force`). +If someone manually edits a generated file, the hash won't match and `sync.py` will refuse to overwrite it (unless run with `--force`). ## Working on CodeCannon @@ -167,7 +167,7 @@ The primary commands for working on this project: 1. Create `adapters/{name}/config.yaml` — set `agent`, `output_directory`, and `output_extension`. 2. Create `adapters/{name}/header.md` — invocation header template using `{skill}` and `{description}`. -3. Test with `./sync.sh --skill start`. +3. Test with `./sync.py --skill start`. 4. Document any adapter limitations in `config.yaml` under `notes`. 5. Update `docs/adapters.md`. @@ -181,7 +181,7 @@ The primary commands for working on this project: ## Self-hosting: using CodeCannon to develop CodeCannon -This repo is the only project that runs sync as `./sync.sh` instead of `CodeCannon/sync.sh`. Every consumer project uses the submodule path — this is the exception. +This repo is the only project that runs sync as `./sync.py` instead of `CodeCannon/sync.py`. Every consumer project uses the submodule path — this is the exception. - **Edit-test loop**: edit a skill in `skills/` → `make check` → `make dev` → `make sync` → commit source + generated files together. - **All adapter outputs are generated**: after editing a skill, `make sync` regenerates `.claude/commands/`, `.cursor/rules/`, `.agents/skills/`, and `.gemini/skills/`. Commit all of them. @@ -215,7 +215,7 @@ docs/ user-facing documentation skills/ per-skill documentation config.schema.yaml placeholder documentation (canonical) .codecannon.yaml this project's config -sync.sh the sync engine +sync.py the sync engine Makefile project-specific targets Makefile.agents.mk generic git workflow targets VERSION current version diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3f4862a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 LightbridgeLab + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index 75c1bff..92b5e98 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ INTEGRATION_BRANCH = dev include Makefile.agents.mk .DEFAULT_GOAL := help -.PHONY: help check dev sync bump-patch bump-minor bump-major set-version deploy-preview deploy-prod +.PHONY: help check dev sync test bump-patch bump-minor bump-major set-version deploy-preview deploy-prod help: @echo "Usage: make " @@ -16,6 +16,7 @@ help: @echo " check Validate skill placeholders against config" @echo " dev Preview sync output (dry run)" @echo " sync Regenerate adapter output from skills/" + @echo " test Run the sync engine test suite" @echo "" @echo "Git workflow (from Makefile.agents.mk)" @echo " branch name=X Create feature branch from $(INTEGRATION_BRANCH)" @@ -35,16 +36,20 @@ help: # Validate that all skill placeholders resolve against the config. check: - ./sync.sh --validate + ./sync.py --validate # Preview what sync would generate without writing any files. dev: - ./sync.sh --validate - ./sync.sh --dry-run + ./sync.py --validate + ./sync.py --dry-run # Regenerate .claude/commands/ and other adapter output from skills/. sync: - ./sync.sh + ./sync.py + +# Run the sync engine test suite. +test: + python3 -m unittest discover -s tests -v # Bump the patch segment (X.Y.Z → X.Y.Z+1), commit, and tag. bump-patch: diff --git a/README.md b/README.md index 7535f6f..d1009bb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ ![Code Cannon](.github/assets/readme-header.png) +[![CI](https://github.com/LightbridgeLab/CodeCannon/actions/workflows/sync-check.yml/badge.svg)](https://github.com/LightbridgeLab/CodeCannon/actions/workflows/sync-check.yml) [![Tests](https://github.com/LightbridgeLab/CodeCannon/actions/workflows/test.yml/badge.svg)](https://github.com/LightbridgeLab/CodeCannon/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/LightbridgeLab/CodeCannon/branch/main/graph/badge.svg)](https://codecov.io/gh/LightbridgeLab/CodeCannon) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![GitHub release](https://img.shields.io/github/v/release/LightbridgeLab/CodeCannon)](https://github.com/LightbridgeLab/CodeCannon/releases) [![Last Commit](https://img.shields.io/github/last-commit/LightbridgeLab/CodeCannon)](https://github.com/LightbridgeLab/CodeCannon/commits) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](docs/contributing.md) [![Python](https://img.shields.io/badge/Python-3.x-blue?logo=python&logoColor=white)](sync.py) [![Install](https://img.shields.io/badge/install-git%20submodule-blue?logo=git&logoColor=white)](https://github.com/LightbridgeLab/CodeCannon#quick-start) + # Code Cannon Write your team's AI agent workflow once — start, submit-for-review, review, deploy — and sync it to Claude Code, Cursor, Gemini, and Codex across all your projects. @@ -13,7 +15,7 @@ AI coding agents are powerful, but every project reinvents the same workflows: h Code Cannon is a shared skill library that lives as a git submodule. Skills are written once as portable markdown. A sync script reads your project config and generates agent-specific command files: ``` -skills/*.md → sync.sh + .codecannon.yaml → .claude/commands/*.md +skills/*.md → sync.py + .codecannon.yaml → .claude/commands/*.md → .cursor/rules/*.mdc ``` @@ -49,10 +51,12 @@ Plus `/qa` for structured QA workflows and `/setup` for guided onboarding. ## Quick start +Requires Python 3.8+ (stdlib only — no pip install needed). + ```bash git submodule add https://github.com/LightbridgeLab/CodeCannon.git CodeCannon cp CodeCannon/templates/codecannon.yaml .codecannon.yaml -CodeCannon/sync.sh +CodeCannon/sync.py ``` Then optionally run `/setup` for a guided walkthrough. @@ -60,18 +64,18 @@ Then optionally run `/setup` for a guided walkthrough. To update to the latest version: ```bash -CodeCannon/sync.sh --update +CodeCannon/sync.py --update ``` ## Documentation - **[Getting started](docs/index.md)** — full quickstart, migration guide, and workflow details - **[Branching models](docs/branching.md)** — trunk, two-branch, and three-branch workflows -- **[Customization](docs/customization.md)** — tailoring skills, sync.sh reference +- **[Customization](docs/customization.md)** — tailoring skills, sync.py reference - **[Config reference](docs/config-reference.md)** — every `.codecannon.yaml` setting documented - **[Adapters](docs/adapters.md)** — supported agents and how to add new ones - **Skill reference:** [/start](docs/skills/start.md) · [/submit-for-review](docs/skills/submit-for-review.md) · [/review](docs/skills/review.md) · [/deploy](docs/skills/deploy.md) · [/qa](docs/skills/qa.md) · [/status](docs/skills/status.md) · [/setup](docs/skills/setup.md) ## License -MIT +[MIT](LICENSE) diff --git a/VERSION b/VERSION index 17b2ccd..8f0916f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.3 +0.5.0 diff --git a/docs/adapters.md b/docs/adapters.md index 45a19ab..ad33bad 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -45,7 +45,7 @@ adapters: - gemini ``` -Run `sync.sh` to generate files for all listed adapters. You can enable both simultaneously — they write to different directories and don't conflict. +Run `sync.py` to generate files for all listed adapters. You can enable both simultaneously — they write to different directories and don't conflict. ## How adapters work @@ -70,7 +70,7 @@ To support a new AI coding agent: 2. Create `adapters//header.md` — the invocation header that tells the agent how to interpret the skill. Use `{skill}` and `{description}` as placeholders for the skill name and description. -3. Test with `sync.sh --skill start` against a test project. +3. Test with `sync.py --skill start` against a test project. 4. Document any adapter limitations in `config.yaml` under `notes`. diff --git a/docs/branching.md b/docs/branching.md index bc06611..6964337 100644 --- a/docs/branching.md +++ b/docs/branching.md @@ -84,9 +84,9 @@ The agent will not proceed past these checks. There is no override flag — if y When working on multiple features simultaneously, **sequence tasks that touch overlapping files — don't parallelize them.** Submit and merge the first branch before starting the second. -This is especially important for tasks that involve renaming, restructuring, or updating cross-references across skills. These changes tend to be broad in scope (touching many files) even though the individual edits are small. Running `sync.sh` amplifies the problem further: a one-line edit in a source skill becomes changes across every adapter directory (`.claude/`, `.cursor/`, `.agents/`, `.gemini/`), multiplying the conflict surface. +This is especially important for tasks that involve renaming, restructuring, or updating cross-references across skills. These changes tend to be broad in scope (touching many files) even though the individual edits are small. Running `sync.py` amplifies the problem further: a one-line edit in a source skill becomes changes across every adapter directory (`.claude/`, `.cursor/`, `.agents/`, `.gemini/`), multiplying the conflict surface. -If you do end up with parallel branches that conflict, the cleanest resolution is usually to re-apply the smaller change on a fresh branch off the updated integration branch, rather than resolving conflicts file-by-file. The mechanical nature of most cross-cutting changes (find-and-replace + `sync.sh`) makes this fast and reliable. +If you do end up with parallel branches that conflict, the cleanest resolution is usually to re-apply the smaller change on a fresh branch off the updated integration branch, rather than resolving conflicts file-by-file. The mechanical nature of most cross-cutting changes (find-and-replace + `sync.py`) makes this fast and reliable. ## Cleaning up merged branches diff --git a/docs/config-reference.md b/docs/config-reference.md index eef34dc..8572236 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -1,6 +1,6 @@ # Config Reference -All placeholders available in `.codecannon.yaml`. Each is substituted into skill files by `sync.sh`. +All placeholders available in `.codecannon.yaml`. Each is substituted into skill files by `sync.py`. For the canonical source, see [`config.schema.yaml`](../config.schema.yaml). @@ -73,4 +73,4 @@ adapters: - cursor ``` -This controls which agent adapters `sync.sh` generates files for. See [adapters](adapters.md) for details on each. +This controls which agent adapters `sync.py` generates files for. See [adapters](adapters.md) for details on each. diff --git a/docs/contributing.md b/docs/contributing.md index 00301a6..0ce9a48 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -14,14 +14,14 @@ Install it once per clone: cat > .git/hooks/pre-commit << 'EOF' #!/bin/sh # Ensure agent skill files are in sync before committing. -# sync.sh --dry-run exits 1 if any files need regeneration. -./sync.sh --dry-run 2>/dev/null +# sync.py --dry-run exits 1 if any files need regeneration. +./sync.py --dry-run 2>/dev/null if [ $? -ne 0 ]; then - echo "Skills are out of sync. Run ./sync.sh --force before committing." + echo "Skills are out of sync. Run ./sync.py --force before committing." exit 1 fi EOF chmod +x .git/hooks/pre-commit ``` -If a commit is rejected, run `./sync.sh --force` and retry. +If a commit is rejected, run `./sync.py --force` and retry. diff --git a/docs/customization.md b/docs/customization.md index 2dcecd6..5b13479 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -4,7 +4,7 @@ Code Cannon is designed to be configured, not forked. All project-specific behav ## How placeholders work -Skills in `skills/` use `{{PLACEHOLDER}}` tokens wherever behavior needs to vary between projects. When you run `sync.sh`, it reads your `.codecannon.yaml`, substitutes each placeholder with your project's value, and writes the generated skill files. +Skills in `skills/` use `{{PLACEHOLDER}}` tokens wherever behavior needs to vary between projects. When you run `sync.py`, it reads your `.codecannon.yaml`, substitutes each placeholder with your project's value, and writes the generated skill files. For example, the `/submit-for-review` skill contains: @@ -125,24 +125,24 @@ After running sync, add project-specific sections to `AGENTS.md` below the separ The `AGENTS.md` template provides the structure. Your additions live alongside the generated content and give agents broader context about your project beyond what the skill placeholders cover. -## sync.sh and manual edits +## sync.py and manual edits Every generated file includes a hash comment in its header: ``` - + ``` -When you run `sync.sh`, it computes the expected hash for each file. If the file on disk has a different hash (because you edited it manually), sync.sh warns and skips that file. This protects intentional customizations. +When you run `sync.py`, it computes the expected hash for each file. If the file on disk has a different hash (because you edited it manually), sync.py warns and skips that file. This protects intentional customizations. -To overwrite anyway: `sync.sh --force`. +To overwrite anyway: `sync.py --force`. -To regenerate only specific skills: `sync.sh --skill start,submit-for-review`. +To regenerate only specific skills: `sync.py --skill start,submit-for-review`. -### sync.sh reference +### sync.py reference ``` -./CodeCannon/sync.sh [options] +./CodeCannon/sync.py [options] Options: --config path Project config file (default: .codecannon.yaml) diff --git a/docs/index.md b/docs/index.md index 6202157..74b590c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,8 +5,8 @@ Code Cannon is a portable agent workflow skill library. Write your team's develo ## How it works 1. **Skills** live in `skills/` as plain markdown with `{{PLACEHOLDER}}` tokens for project-specific values. -2. **`sync.sh`** reads your project config (`.codecannon.yaml`), substitutes values, wraps each skill in an agent-specific invocation header, and writes the generated files to the right place (`.claude/commands/`, `.cursor/rules/`, etc.). -3. **Generated files** carry a hash so sync.sh can detect manual edits and warn before overwriting. +2. **`sync.py`** reads your project config (`.codecannon.yaml`), substitutes values, wraps each skill in an agent-specific invocation header, and writes the generated files to the right place (`.claude/commands/`, `.cursor/rules/`, etc.). +3. **Generated files** carry a hash so sync.py can detect manual edits and warn before overwriting. ## The workflow @@ -37,6 +37,8 @@ The agent commits; you test. `/start` writes code but does not commit — it han ## Quickstart +**Requires Python 3.8+** (stdlib only — no pip install needed). + ### 1. Add Code Cannon as a submodule ```bash @@ -55,7 +57,7 @@ Edit `.codecannon.yaml` — set your branch names, check command, deploy command ### 3. Run sync ```bash -CodeCannon/sync.sh +CodeCannon/sync.py ``` This generates skill files for each adapter listed in your config. For Claude Code, that's `.claude/commands/*.md`. For Cursor, `.cursor/rules/*.mdc`. @@ -89,10 +91,10 @@ Or skip all of this and run `/setup` for a guided walkthrough. ```bash git submodule update --remote CodeCannon # pull latest skills -CodeCannon/sync.sh # regenerate skill files +CodeCannon/sync.py # regenerate skill files ``` -If any generated files have been manually customized, sync.sh will warn and skip them. Use `--force` to overwrite. +If any generated files have been manually customized, sync.py will warn and skip them. Use `--force` to overwrite. ## Migrating from the old `agentgate` submodule @@ -100,7 +102,7 @@ If your project still uses the previous repo URL or folder name: 1. Point `.gitmodules` at `https://github.com/LightbridgeLab/CodeCannon.git` and use submodule path `CodeCannon/` (or rename your existing checkout to match). 2. Rename `.agentgate.yaml` to `.codecannon.yaml`. -3. Run `CodeCannon/sync.sh --force` once if needed so generated file headers match the new provenance marker. +3. Run `CodeCannon/sync.py --force` once if needed so generated file headers match the new provenance marker. ## Further reading diff --git a/docs/skills/setup.md b/docs/skills/setup.md index 5cc1869..681b8d1 100644 --- a/docs/skills/setup.md +++ b/docs/skills/setup.md @@ -9,7 +9,7 @@ First-run onboarding and configuration walkthrough. `/setup` detects the current state of Code Cannon in your project and guides you through configuration. It handles three scenarios: 1. **You're in the Code Cannon repo itself** — explains how Code Cannon works and offers to help you add it to your project. -2. **Partial setup** — the submodule exists but something is missing (sync.sh not initialized, `gh` not installed, config file missing, etc.). Walks through fixes one at a time. +2. **Partial setup** — the submodule exists but something is missing (sync.py not initialized, `gh` not installed, config file missing, etc.). Walks through fixes one at a time. 3. **Fully configured** — runs a health check, offers to populate labels from your GitHub repo, and walks through optional config values. ## Usage @@ -24,7 +24,7 @@ No arguments. Run it in any project where Code Cannon is (or should be) installe When the submodule is present, `/setup` runs these checks in order, stopping at the first failure: -1. **`CodeCannon/sync.sh` present** — submodule initialized? +1. **`CodeCannon/sync.py` present** — submodule initialized? 2. **`gh` installed** — GitHub CLI available? 3. **`gh` authenticated** — logged in to GitHub? 4. **Inside a GitHub repo** — remote configured? diff --git a/skills/qa.md b/skills/qa.md index 0512bb5..969a3b7 100644 --- a/skills/qa.md +++ b/skills/qa.md @@ -8,7 +8,7 @@ args: "none | issue number" {{#if !QA_READY_LABEL}} > **QA workflow is not configured.** `/qa` requires `QA_READY_LABEL` to be set in `.codecannon.yaml` so it can find issues waiting for QA. > -> To enable: add `QA_READY_LABEL: ready-for-qa` (or your preferred label name) to `.codecannon.yaml` and re-run `CodeCannon/sync.sh`. +> To enable: add `QA_READY_LABEL: ready-for-qa` (or your preferred label name) to `.codecannon.yaml` and re-run `CodeCannon/sync.py`. > > Note: In trunk mode, `/submit-for-review` does not apply this label automatically — you would need to apply it manually or via a separate workflow. diff --git a/skills/review.md b/skills/review.md index d9834af..74e2b99 100644 --- a/skills/review.md +++ b/skills/review.md @@ -15,7 +15,7 @@ args: "PR number (optional — defaults to current branch's open PR)" If `{{REVIEW_GATE}}` is `"off"`, say: -> "Code review is disabled for this project (REVIEW_GATE is set to 'off' in .codecannon.yaml). To enable reviews, set REVIEW_GATE to 'ai' or 'advisory' and re-run CodeCannon/sync.sh." +> "Code review is disabled for this project (REVIEW_GATE is set to 'off' in .codecannon.yaml). To enable reviews, set REVIEW_GATE to 'ai' or 'advisory' and re-run CodeCannon/sync.py." Do not proceed. diff --git a/skills/setup.md b/skills/setup.md index d4d59ea..f6b63b4 100644 --- a/skills/setup.md +++ b/skills/setup.md @@ -12,7 +12,7 @@ Before taking any action, determine which state the project is in. Run these che **Check A — Is this the Code Cannon skill library repo itself?** ```bash -test -f sync.sh && test -d skills +test -f sync.py && test -d skills ``` If both exist at the working directory root → **go to State 1**. @@ -50,7 +50,7 @@ Offer two forward paths and ask which they want: Explain the three-layer model: - **Skills** (`skills/*.md`) — portable workflow instructions with `{{BRANCH_PROD}}`-style tokens for project-specific values (see `config.schema.yaml`) - **Config** (`.codecannon.yaml`) — a project's values that fill those tokens at sync time -- **Sync** (`sync.sh`) — reads the config, substitutes values, and writes generated command files for each adapter (Claude Code → `.claude/commands/`, Cursor → `.cursor/rules/`) +- **Sync** (`sync.py`) — reads the config, substitutes values, and writes generated command files for each adapter (Claude Code → `.claude/commands/`, Cursor → `.cursor/rules/`) Point to README.md for the full skill list and documentation. Do not touch any file. @@ -64,7 +64,7 @@ git submodule add https://github.com/LightbridgeLab/CodeCannon.git CodeCannon git submodule update --init cp CodeCannon/templates/codecannon.yaml .codecannon.yaml # Edit .codecannon.yaml — set branch names, commands, adapters -CodeCannon/sync.sh +CodeCannon/sync.py ``` Do not touch any file. The user runs these commands in their project directory. @@ -75,10 +75,10 @@ Do not touch any file. The user runs these commands in their project directory. Run checks 1–7 in order. Stop at the first failing check and address it. After describing the fix, tell the user to run `/setup` again once they've resolved it. Do not continue past a failing check. -### Check 1 — CodeCannon/sync.sh present +### Check 1 — CodeCannon/sync.py present ```bash -test -f CodeCannon/sync.sh +test -f CodeCannon/sync.py ``` If missing: the submodule was added to `.gitmodules` or `CodeCannon/` exists as an empty directory, but it hasn't been initialized. Show: @@ -189,18 +189,18 @@ If anything is flagged: show the specific key names and what they should likely ### Check 7 — Generated skill output present -Check whether sync.sh has been run by looking for any of the adapter output directories configured in `.codecannon.yaml`: +Check whether sync.py has been run by looking for any of the adapter output directories configured in `.codecannon.yaml`: ```bash test -d .claude/commands || test -d .cursor/rules || test -d .agents/skills || test -d .gemini/skills ``` -If none exist: "sync.sh hasn't been run yet — the skill commands don't exist." +If none exist: "sync.py hasn't been run yet — the skill commands don't exist." Show: ```bash -CodeCannon/sync.sh +CodeCannon/sync.py ``` Ask permission to run it. If the user agrees, run it. If they decline, tell them to run it manually before continuing. Stop. @@ -362,8 +362,8 @@ Add a note: `/start` can be used to create well-formed GitHub issues without wri ## Hard rules -- Only modify `.codecannon.yaml`. Do not touch any other file (except running `CodeCannon/sync.sh`, which modifies `.claude/commands/` — permitted only with explicit user approval). -- Do not run `sync.sh` without explicit user permission. +- Only modify `.codecannon.yaml`. Do not touch any other file (except running `CodeCannon/sync.py`, which modifies `.claude/commands/` — permitted only with explicit user approval). +- Do not run `sync.py` without explicit user permission. - Do not create `.codecannon.yaml` without explicit user permission. - Do not report a configuration problem unless confident the condition is genuinely broken. Prefer false negatives over false positives on all diagnostic checks. - Never fetch more than 100 labels in a single command. `gh label list --limit 100` is the ceiling. diff --git a/sync.sh b/sync.py similarity index 93% rename from sync.sh rename to sync.py index af6e906..7f35d11 100755 --- a/sync.sh +++ b/sync.py @@ -1,18 +1,18 @@ #!/usr/bin/env python3 """ -CodeCannon/sync.sh — generate agent-specific skill files from source skills + project config +CodeCannon/sync.py — generate agent-specific skill files from source skills + project config Usage: - ./CodeCannon/sync.sh # use .codecannon.yaml in current dir - ./CodeCannon/sync.sh --config path/to/config.yaml - ./CodeCannon/sync.sh --force # overwrite customized files without prompting - ./CodeCannon/sync.sh --dry-run # preview what would be written, exits 1 if any writes pending - ./CodeCannon/sync.sh --validate # check all placeholders are defined, exits 1 if any missing - ./CodeCannon/sync.sh --skill start # sync only the named skill(s), comma-separated - -Generated files carry a hash comment so sync.sh can detect manual edits. -If a generated file has been customized (hash mismatch), sync.sh warns and skips it. -Pass --force to overwrite, or delete the file to let sync.sh regenerate it cleanly. + ./CodeCannon/sync.py # use .codecannon.yaml in current dir + ./CodeCannon/sync.py --config path/to/config.yaml + ./CodeCannon/sync.py --force # overwrite customized files without prompting + ./CodeCannon/sync.py --dry-run # preview what would be written, exits 1 if any writes pending + ./CodeCannon/sync.py --validate # check all placeholders are defined, exits 1 if any missing + ./CodeCannon/sync.py --skill start # sync only the named skill(s), comma-separated + +Generated files carry a hash comment so sync.py can detect manual edits. +If a generated file has been customized (hash mismatch), sync.py warns and skips it. +Pass --force to overwrite, or delete the file to let sync.py regenerate it cleanly. """ import sys @@ -24,13 +24,16 @@ from pathlib import Path CODECANNON_DIR = Path(__file__).parent -MARKER = "generated by CodeCannon/sync.sh" -LEGACY_MARKER = "generated by agentgate/sync.sh" +MARKER = "generated by CodeCannon/sync.py" +LEGACY_MARKERS = [ + "generated by CodeCannon/sync.sh", + "generated by agentgate/sync.sh", +] def first_line_has_sync_marker(first_line): """True if the line was produced by this tool (current or legacy marker).""" - return MARKER in first_line or LEGACY_MARKER in first_line + return MARKER in first_line or any(m in first_line for m in LEGACY_MARKERS) # ── YAML parsing ────────────────────────────────────────────────────────────── @@ -192,7 +195,7 @@ def read_file_info(file_path): stored_hash — hash recorded in the marker line when the file was last written body_hash — hash of the file's actual content (excluding the marker line) is_generated — True if the file was written by this sync tool (current or legacy marker) - needs_migration — True if the marker is in legacy position (start) and should be moved to end + needs_migration — True if the marker needs rewriting (legacy position or legacy marker text) Comparing stored_hash vs body_hash tells us whether the file was edited after the last sync. Comparing body_hash vs the freshly-computed h tells @@ -232,7 +235,11 @@ def read_file_info(file_path): body = '\n'.join(lines[1:]) + '\n' body_hash = content_hash(body) - return stored_hash, body_hash, True, (marker_pos == 'start') + # Migration needed if marker is at the start (legacy position) or uses a legacy marker string + has_legacy_marker_text = MARKER not in marker_line and any(m in marker_line for m in LEGACY_MARKERS) + migrate = (marker_pos == 'start') or has_legacy_marker_text + + return stored_hash, body_hash, True, migrate # ── Adapter loading ─────────────────────────────────────────────────────────── @@ -306,7 +313,7 @@ def sync_skill(skill_path, adapter, project_config, project_root, args): # Compute hash (of content, before marker line) h = content_hash(full_content) - marker_line = f"" + marker_line = f"" final_content = full_content + marker_line + '\n' # Check existing file diff --git a/templates/AGENTS.md.template b/templates/AGENTS.md.template index 81e16e3..aa7855e 100644 --- a/templates/AGENTS.md.template +++ b/templates/AGENTS.md.template @@ -37,8 +37,8 @@ This project uses the **Lightweight / Standard / Governed** workflow profile. Se `.codecannon.yaml` and the generated `.claude/commands/` files are committed to this repo. Every developer gets a consistent workflow on `git clone` + `git submodule update --init` — no further setup required. -To update skills: `git submodule update --remote CodeCannon && CodeCannon/sync.sh` -To reconfigure: edit `.codecannon.yaml` and re-run `CodeCannon/sync.sh`, then commit the changes. +To update skills: `git submodule update --remote CodeCannon && CodeCannon/sync.py` +To reconfigure: edit `.codecannon.yaml` and re-run `CodeCannon/sync.py`, then commit the changes. ## Agent Workflow diff --git a/templates/codecannon.yaml b/templates/codecannon.yaml index a139182..4144dfd 100644 --- a/templates/codecannon.yaml +++ b/templates/codecannon.yaml @@ -1,7 +1,7 @@ # .codecannon.yaml — project configuration for Code Cannon skill sync # # Copy this file to your project root as .codecannon.yaml and customize. -# Run: CodeCannon/sync.sh +# Run: CodeCannon/sync.py # # See CodeCannon/config.schema.yaml for documentation on each placeholder. @@ -14,7 +14,7 @@ adapters: # - codex # Placeholder values substituted into skill files during sync. -# Remove any you don't need; sync.sh will warn about unresolved placeholders. +# Remove any you don't need; sync.py will warn about unresolved placeholders. config: # ── Workflow profile ───────────────────────────────────────────────────────── diff --git a/templates/github/workflows/sync-check.yml b/templates/github/workflows/sync-check.yml index 9e3a613..b98c8d7 100644 --- a/templates/github/workflows/sync-check.yml +++ b/templates/github/workflows/sync-check.yml @@ -7,5 +7,5 @@ jobs: - uses: actions/checkout@v4 - name: Check for sync drift run: | - CodeCannon/sync.sh --dry-run + CodeCannon/sync.py --dry-run # exits non-zero if any files would be written diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..74f37fe --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,822 @@ +"""Tests for CodeCannon sync.py — the sync engine.""" + +import hashlib +import os +import sys +import tempfile +import textwrap +import unittest +from pathlib import Path +from unittest.mock import patch + +# Add the repo root to the path so we can import sync as a module. +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +import sync + + +# ── Helpers ────────────────────────────────────────────────────────────────── + +def _make_skill(tmpdir, name, frontmatter, body): + """Write a skill markdown file into tmpdir/skills/.md.""" + skills_dir = Path(tmpdir) / "skills" + skills_dir.mkdir(exist_ok=True) + path = skills_dir / f"{name}.md" + content = f"---\n{frontmatter}\n---\n\n{body}" + path.write_text(content) + return path + + +def _make_adapter(tmpdir, name, config_yaml, header_md=""): + """Write an adapter directory into tmpdir/adapters//.""" + adapter_dir = Path(tmpdir) / "adapters" / name + adapter_dir.mkdir(parents=True, exist_ok=True) + (adapter_dir / "config.yaml").write_text(config_yaml) + if header_md: + (adapter_dir / "header.md").write_text(header_md) + return adapter_dir + + +def _make_args(**overrides): + """Return a minimal argparse-like namespace for sync_skill.""" + defaults = {"force": False, "dry_run": False} + defaults.update(overrides) + return type("Args", (), defaults)() + + +# ═══════════════════════════════════════════════════════════════════════════════ +# YAML PARSING +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestDequote(unittest.TestCase): + + def test_double_quoted(self): + self.assertEqual(sync._dequote('"hello world"'), "hello world") + + def test_single_quoted(self): + self.assertEqual(sync._dequote("'hello world'"), "hello world") + + def test_unquoted(self): + self.assertEqual(sync._dequote("hello"), "hello") + + def test_double_quoted_escape_quote(self): + self.assertEqual(sync._dequote(r'"say \"hi\""'), 'say "hi"') + + def test_double_quoted_escape_backslash(self): + self.assertEqual(sync._dequote(r'"back\\slash"'), "back\\slash") + + def test_single_quoted_no_escapes(self): + self.assertEqual(sync._dequote(r"'no \" escapes'"), r'no \" escapes') + + def test_empty_double_quotes(self): + self.assertEqual(sync._dequote('""'), "") + + def test_empty_single_quotes(self): + self.assertEqual(sync._dequote("''"), "") + + def test_single_char(self): + self.assertEqual(sync._dequote("x"), "x") + + def test_empty_string(self): + self.assertEqual(sync._dequote(""), "") + + +class TestParseYamlSimple(unittest.TestCase): + + def test_flat_key_value(self): + text = "name: Alice\nage: 30" + result = sync.parse_yaml_simple(text) + self.assertEqual(result, {"name": "Alice", "age": "30"}) + + def test_nested_dict(self): + text = "config:\n FOO: bar\n BAZ: qux" + result = sync.parse_yaml_simple(text) + self.assertEqual(result, {"config": {"FOO": "bar", "BAZ": "qux"}}) + + def test_list(self): + text = "adapters:\n - claude\n - cursor" + result = sync.parse_yaml_simple(text) + self.assertEqual(result, {"adapters": ["claude", "cursor"]}) + + def test_comments_and_blanks_ignored(self): + text = "# comment\n\nkey: value\n # nested comment" + result = sync.parse_yaml_simple(text) + self.assertEqual(result, {"key": "value"}) + + def test_quoted_values(self): + text = 'config:\n MSG: "hello world"\n OTHER: \'single\'' + result = sync.parse_yaml_simple(text) + self.assertEqual(result["config"]["MSG"], "hello world") + self.assertEqual(result["config"]["OTHER"], "single") + + def test_empty_value_creates_dict(self): + text = "config:" + result = sync.parse_yaml_simple(text) + self.assertEqual(result, {"config": {}}) + + def test_mixed_list_after_dict_key(self): + """If a key starts as a dict, then encounters a list item, it converts.""" + text = "items:\n - alpha\n - beta" + result = sync.parse_yaml_simple(text) + self.assertEqual(result["items"], ["alpha", "beta"]) + + +class TestParseFrontmatter(unittest.TestCase): + + def test_basic_frontmatter(self): + text = "---\nskill: deploy\ntype: skill\n---\n\nBody content here." + fm, body = sync.parse_frontmatter(text) + self.assertEqual(fm["skill"], "deploy") + self.assertEqual(fm["type"], "skill") + self.assertEqual(body, "Body content here.") + + def test_no_frontmatter(self): + text = "Just a body with no frontmatter." + fm, body = sync.parse_frontmatter(text) + self.assertEqual(fm, {}) + self.assertEqual(body, "Just a body with no frontmatter.") + + def test_quoted_description(self): + text = '---\ndescription: "My cool skill"\n---\n\nBody.' + fm, body = sync.parse_frontmatter(text) + self.assertEqual(fm["description"], "My cool skill") + + def test_inline_list_in_frontmatter(self): + text = "---\ntags: [foo, bar, baz]\n---\n\nBody." + fm, body = sync.parse_frontmatter(text) + self.assertEqual(fm["tags"], ["foo", "bar", "baz"]) + + def test_body_stripped(self): + text = "---\nk: v\n---\n\n Body with leading space. \n\nTrailing." + fm, body = sync.parse_frontmatter(text) + self.assertIn("Body with leading space.", body) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# CONDITIONAL BLOCKS +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestApplyConditionals(unittest.TestCase): + + def test_truthy_keeps_block(self): + text = "before\n{{#if FOO}}\nkept\n{{/if}}\nafter" + result = sync.apply_conditionals(text, {"FOO": "yes"}) + self.assertIn("kept", result) + self.assertIn("before", result) + self.assertIn("after", result) + self.assertNotIn("{{#if", result) + self.assertNotIn("{{/if}}", result) + + def test_falsy_removes_block(self): + text = "before\n{{#if FOO}}\nremoved\n{{/if}}\nafter" + result = sync.apply_conditionals(text, {"FOO": ""}) + self.assertNotIn("removed", result) + self.assertIn("before", result) + self.assertIn("after", result) + + def test_missing_key_is_falsy(self): + text = "{{#if MISSING}}\nhidden\n{{/if}}\nvisible" + result = sync.apply_conditionals(text, {}) + self.assertNotIn("hidden", result) + self.assertIn("visible", result) + + def test_negated_truthy_removes_block(self): + text = "{{#if !FOO}}\nhidden\n{{/if}}\nvisible" + result = sync.apply_conditionals(text, {"FOO": "yes"}) + self.assertNotIn("hidden", result) + self.assertIn("visible", result) + + def test_negated_falsy_keeps_block(self): + text = "{{#if !FOO}}\nkept\n{{/if}}\nvisible" + result = sync.apply_conditionals(text, {"FOO": ""}) + self.assertIn("kept", result) + + def test_boolean_false_string_is_falsy(self): + for val in ("false", "False", "FALSE", "no", "No", "0"): + text = "{{#if FLAG}}\nshown\n{{/if}}" + result = sync.apply_conditionals(text, {"FLAG": val}) + self.assertNotIn("shown", result, f"Expected '{val}' to be falsy") + + def test_nested_conditionals(self): + text = textwrap.dedent("""\ + {{#if OUTER}} + outer-start + {{#if INNER}} + inner-content + {{/if}} + outer-end + {{/if}}""") + result = sync.apply_conditionals(text, {"OUTER": "yes", "INNER": "yes"}) + self.assertIn("outer-start", result) + self.assertIn("inner-content", result) + self.assertIn("outer-end", result) + + def test_nested_outer_false_removes_all(self): + text = textwrap.dedent("""\ + {{#if OUTER}} + outer-start + {{#if INNER}} + inner-content + {{/if}} + outer-end + {{/if}}""") + result = sync.apply_conditionals(text, {"OUTER": "", "INNER": "yes"}) + self.assertNotIn("outer-start", result) + self.assertNotIn("inner-content", result) + + def test_multiple_independent_blocks(self): + text = "{{#if A}}\nalpha\n{{/if}}\n{{#if B}}\nbeta\n{{/if}}" + result = sync.apply_conditionals(text, {"A": "yes", "B": ""}) + self.assertIn("alpha", result) + self.assertNotIn("beta", result) + + def test_malformed_no_open_tag(self): + """A lone {{/if}} should not crash — processing just stops.""" + text = "content\n{{/if}}\nmore" + result = sync.apply_conditionals(text, {}) + # The malformed block stops processing; the text is returned as-is + self.assertIn("content", result) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# PLACEHOLDER SUBSTITUTION +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestApplyPlaceholders(unittest.TestCase): + + def test_basic_substitution(self): + text = "Branch: {{BRANCH_PROD}}" + result = sync.apply_placeholders(text, {"BRANCH_PROD": "main"}) + self.assertEqual(result, "Branch: main") + + def test_multiple_placeholders(self): + text = "{{A}} and {{B}}" + result = sync.apply_placeholders(text, {"A": "alpha", "B": "beta"}) + self.assertEqual(result, "alpha and beta") + + def test_repeated_placeholder(self): + text = "{{X}} then {{X}}" + result = sync.apply_placeholders(text, {"X": "val"}) + self.assertEqual(result, "val then val") + + def test_unresolved_left_alone(self): + text = "{{KNOWN}} and {{UNKNOWN}}" + result = sync.apply_placeholders(text, {"KNOWN": "ok"}) + self.assertEqual(result, "ok and {{UNKNOWN}}") + + def test_empty_values_dict(self): + text = "nothing {{HERE}}" + result = sync.apply_placeholders(text, {}) + self.assertEqual(result, "nothing {{HERE}}") + + +class TestFindUnresolved(unittest.TestCase): + + def test_finds_unresolved(self): + text = "{{RESOLVED}} and {{MISSING}}" + result = sync.find_unresolved(text) + self.assertEqual(result, ["RESOLVED", "MISSING"]) + + def test_no_unresolved(self): + text = "plain text" + result = sync.find_unresolved(text) + self.assertEqual(result, []) + + def test_ignores_lowercase(self): + text = "{{lowercase}}" + result = sync.find_unresolved(text) + self.assertEqual(result, []) + + def test_multiple_same(self): + text = "{{A}} and {{A}}" + result = sync.find_unresolved(text) + self.assertEqual(result, ["A", "A"]) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# HASH AND CHANGE DETECTION +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestContentHash(unittest.TestCase): + + def test_deterministic(self): + self.assertEqual(sync.content_hash("hello"), sync.content_hash("hello")) + + def test_different_inputs(self): + self.assertNotEqual(sync.content_hash("a"), sync.content_hash("b")) + + def test_length(self): + h = sync.content_hash("test") + self.assertEqual(len(h), 8) + + def test_matches_md5(self): + expected = hashlib.md5("test".encode()).hexdigest()[:8] + self.assertEqual(sync.content_hash("test"), expected) + + +class TestFirstLineHasSyncMarker(unittest.TestCase): + + def test_current_marker(self): + line = f"" + self.assertTrue(sync.first_line_has_sync_marker(line)) + + def test_legacy_marker(self): + line = "" + self.assertTrue(sync.first_line_has_sync_marker(line)) + + def test_no_marker(self): + self.assertFalse(sync.first_line_has_sync_marker("just a normal line")) + + def test_empty_line(self): + self.assertFalse(sync.first_line_has_sync_marker("")) + + +class TestReadFileInfo(unittest.TestCase): + + def test_nonexistent_file(self): + stored, body_h, is_gen, migrate = sync.read_file_info("/nonexistent/path") + self.assertIsNone(stored) + self.assertFalse(is_gen) + + def test_file_with_current_marker_at_end(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + body = "some content\n" + h = sync.content_hash(body) + marker = f"" + f.write(body + marker + "\n") + f.flush() + stored, body_h, is_gen, migrate = sync.read_file_info(f.name) + os.unlink(f.name) + self.assertEqual(stored, h) + self.assertEqual(body_h, h) + self.assertTrue(is_gen) + self.assertFalse(migrate) + + def test_file_with_legacy_marker_at_start(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + h = "abcd1234" + marker = f"" + body_lines = "body content\n" + # Legacy: marker at first line + f.write(marker + "\n" + body_lines) + f.flush() + stored, body_h, is_gen, migrate = sync.read_file_info(f.name) + os.unlink(f.name) + self.assertEqual(stored, h) + self.assertTrue(is_gen) + self.assertTrue(migrate) # marker at start → needs migration + + def test_file_without_marker(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + f.write("plain file\nno marker\n") + f.flush() + stored, body_h, is_gen, migrate = sync.read_file_info(f.name) + os.unlink(f.name) + self.assertIsNone(stored) + self.assertFalse(is_gen) + self.assertFalse(migrate) + + def test_customized_file_detected(self): + """If the body was edited after sync, body_hash != stored_hash.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + original_body = "original content\n" + original_hash = sync.content_hash(original_body) + marker = f"" + # Write with a modified body but the old hash in the marker + f.write("modified content\n" + marker + "\n") + f.flush() + stored, body_h, is_gen, migrate = sync.read_file_info(f.name) + os.unlink(f.name) + self.assertEqual(stored, original_hash) + self.assertNotEqual(body_h, stored) + self.assertTrue(is_gen) + + def test_legacy_marker_text_detected(self): + """Files with old marker text (sync.sh) need migration.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + body = "content\n" + marker = "" + f.write(body + marker + "\n") + f.flush() + stored, body_h, is_gen, migrate = sync.read_file_info(f.name) + os.unlink(f.name) + self.assertTrue(is_gen) + self.assertTrue(migrate) # legacy marker text + + +# ═══════════════════════════════════════════════════════════════════════════════ +# ADAPTER LOADING +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestLoadAdapter(unittest.TestCase): + + def test_load_claude_adapter(self): + adapter = sync.load_adapter("claude") + self.assertIsNotNone(adapter) + self.assertEqual(adapter["name"], "claude") + self.assertEqual(adapter["output_directory"], ".claude/commands") + self.assertEqual(adapter["output_extension"], ".md") + self.assertIn("{description}", adapter["header_template"]) + + def test_load_cursor_adapter(self): + adapter = sync.load_adapter("cursor") + self.assertIsNotNone(adapter) + self.assertEqual(adapter["name"], "cursor") + self.assertEqual(adapter["output_extension"], ".mdc") + + def test_nonexistent_adapter(self): + adapter = sync.load_adapter("nonexistent_adapter_xyz") + self.assertIsNone(adapter) + + +class TestBuildHeader(unittest.TestCase): + + def test_substitutes_skill_and_description(self): + adapter = { + "header_template": "Skill: {skill}\nDesc: {description}\n", + } + fm = {"description": "My description"} + header = sync.build_header(adapter, "deploy", fm) + self.assertIn("Skill: deploy", header) + self.assertIn("Desc: My description", header) + + def test_defaults_description_to_skill_name(self): + adapter = {"header_template": "{description}"} + fm = {} # no description + header = sync.build_header(adapter, "test-skill", fm) + self.assertEqual(header, "test-skill") + + def test_empty_header_template(self): + adapter = {"header_template": ""} + fm = {"description": "Something"} + header = sync.build_header(adapter, "s", fm) + self.assertEqual(header, "") + + +# ═══════════════════════════════════════════════════════════════════════════════ +# SYNC SKILL (integration-level) +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestSyncSkill(unittest.TestCase): + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.project_root = Path(self.tmpdir) / "project" + self.project_root.mkdir() + self.adapter = { + "name": "test", + "output_directory": ".test/commands", + "output_extension": ".md", + "header_template": "{description}\n\n---\n\n", + } + self.config = {"BRANCH_PROD": "main", "BRANCH_DEV": "dev"} + + def tearDown(self): + import shutil + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def _write_skill(self, name, body, **fm_extra): + fm_lines = [f'skill: {name}', 'type: skill', f'description: "Test skill: {name}"'] + for k, v in fm_extra.items(): + fm_lines.append(f'{k}: "{v}"') + skill_dir = Path(self.tmpdir) / "skills" + skill_dir.mkdir(exist_ok=True) + path = skill_dir / f"{name}.md" + path.write_text(f"---\n" + "\n".join(fm_lines) + "\n---\n\n" + body) + return path + + def test_writes_file_on_first_sync(self): + skill_path = self._write_skill("demo", "Hello {{BRANCH_PROD}}") + args = _make_args() + result = sync.sync_skill(skill_path, self.adapter, self.config, self.project_root, args) + self.assertFalse(result) # not dry-run, so returns False + out = self.project_root / ".test" / "commands" / "demo.md" + self.assertTrue(out.exists()) + content = out.read_text() + self.assertIn("Hello main", content) + self.assertIn(sync.MARKER, content) + + def test_dry_run_does_not_write(self): + skill_path = self._write_skill("demo", "content") + args = _make_args(dry_run=True) + result = sync.sync_skill(skill_path, self.adapter, self.config, self.project_root, args) + self.assertTrue(result) # would write + out = self.project_root / ".test" / "commands" / "demo.md" + self.assertFalse(out.exists()) + + def test_idempotent_second_sync(self): + skill_path = self._write_skill("demo", "stable content") + args = _make_args() + sync.sync_skill(skill_path, self.adapter, self.config, self.project_root, args) + # Second sync should detect up-to-date + result = sync.sync_skill(skill_path, self.adapter, self.config, self.project_root, args) + self.assertFalse(result) + + def test_skips_customized_file_without_force(self): + skill_path = self._write_skill("demo", "original") + args = _make_args() + sync.sync_skill(skill_path, self.adapter, self.config, self.project_root, args) + + # Tamper with the output file body (but keep the marker) + out = self.project_root / ".test" / "commands" / "demo.md" + content = out.read_text() + out.write_text(content.replace("original", "CUSTOMIZED")) + + # Update the source skill to trigger a regeneration attempt + skill_path.write_text(skill_path.read_text().replace("original", "new-source")) + + result = sync.sync_skill(skill_path, self.adapter, self.config, self.project_root, args) + self.assertFalse(result) + # File should still contain the customization + self.assertIn("CUSTOMIZED", out.read_text()) + + def test_force_overwrites_customized_file(self): + skill_path = self._write_skill("demo", "original") + args = _make_args() + sync.sync_skill(skill_path, self.adapter, self.config, self.project_root, args) + + out = self.project_root / ".test" / "commands" / "demo.md" + content = out.read_text() + out.write_text(content.replace("original", "CUSTOMIZED")) + + skill_path.write_text(skill_path.read_text().replace("original", "new-source")) + + args_force = _make_args(force=True) + sync.sync_skill(skill_path, self.adapter, self.config, self.project_root, args_force) + self.assertIn("new-source", out.read_text()) + self.assertNotIn("CUSTOMIZED", out.read_text()) + + def test_conditional_blocks_in_sync(self): + body = textwrap.dedent("""\ + {{#if BRANCH_DEV}} + Dev: {{BRANCH_DEV}} + {{/if}} + {{#if BRANCH_TEST}} + Test: {{BRANCH_TEST}} + {{/if}} + Prod: {{BRANCH_PROD}}""") + skill_path = self._write_skill("cond", body) + args = _make_args() + sync.sync_skill(skill_path, self.adapter, self.config, self.project_root, args) + out = self.project_root / ".test" / "commands" / "cond.md" + content = out.read_text() + self.assertIn("Dev: dev", content) + self.assertNotIn("Test:", content) # BRANCH_TEST not in config + self.assertIn("Prod: main", content) + + def test_no_invocation_header(self): + skill_path = self._write_skill("bare", "bare body", no_invocation_header="true") + args = _make_args() + sync.sync_skill(skill_path, self.adapter, self.config, self.project_root, args) + out = self.project_root / ".test" / "commands" / "bare.md" + content = out.read_text() + # Should NOT have the header (description + ---) + self.assertTrue(content.startswith("bare body")) + + def test_output_path_override(self): + skill_path = self._write_skill( + "custom", "custom body", + output_path_override=".custom/output.md" + ) + args = _make_args() + sync.sync_skill(skill_path, self.adapter, self.config, self.project_root, args) + out = self.project_root / ".custom" / "output.md" + self.assertTrue(out.exists()) + # Default path should NOT exist + default_out = self.project_root / ".test" / "commands" / "custom.md" + self.assertFalse(default_out.exists()) + + def test_skips_non_generated_existing_file(self): + """If a file exists but has no sync marker, skip without --force.""" + out_dir = self.project_root / ".test" / "commands" + out_dir.mkdir(parents=True) + out = out_dir / "demo.md" + out.write_text("User-created file, no marker.\n") + + skill_path = self._write_skill("demo", "new content") + args = _make_args() + result = sync.sync_skill(skill_path, self.adapter, self.config, self.project_root, args) + self.assertFalse(result) + self.assertIn("User-created file", out.read_text()) + + def test_regenerates_when_source_changes(self): + """If source skill changes but output wasn't customized, regenerate.""" + skill_path = self._write_skill("demo", "version-1") + args = _make_args() + sync.sync_skill(skill_path, self.adapter, self.config, self.project_root, args) + + # Update source (rewrite the skill file) + skill_path.write_text(skill_path.read_text().replace("version-1", "version-2")) + + sync.sync_skill(skill_path, self.adapter, self.config, self.project_root, args) + out = self.project_root / ".test" / "commands" / "demo.md" + self.assertIn("version-2", out.read_text()) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# VALIDATION +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestValidatePlaceholders(unittest.TestCase): + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + + def tearDown(self): + import shutil + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def test_no_errors_when_all_defined(self): + path = _make_skill(self.tmpdir, "ok", 'skill: ok\ndescription: "test"', "Use {{FOO}}") + errors = sync.validate_placeholders([path], {"FOO": "bar"}) + self.assertEqual(errors, []) + + def test_reports_undefined_placeholder(self): + path = _make_skill(self.tmpdir, "bad", 'skill: bad\ndescription: "test"', "Use {{MISSING}}") + errors = sync.validate_placeholders([path], {}) + self.assertEqual(len(errors), 1) + self.assertIn("MISSING", errors[0]) + + def test_placeholder_in_stripped_conditional_not_reported(self): + body = "{{#if ACTIVE}}\n{{OPTIONAL}}\n{{/if}}\nPlain text." + path = _make_skill(self.tmpdir, "cond", 'skill: cond\ndescription: "test"', body) + # ACTIVE is falsy → the block is stripped → OPTIONAL should not be reported + errors = sync.validate_placeholders([path], {"ACTIVE": ""}) + self.assertEqual(errors, []) + + def test_placeholder_in_kept_conditional_reported(self): + body = "{{#if ACTIVE}}\n{{OPTIONAL}}\n{{/if}}" + path = _make_skill(self.tmpdir, "cond", 'skill: cond\ndescription: "test"', body) + # ACTIVE is truthy → OPTIONAL is in final output → should be reported + errors = sync.validate_placeholders([path], {"ACTIVE": "yes"}) + self.assertEqual(len(errors), 1) + self.assertIn("OPTIONAL", errors[0]) + + def test_description_placeholders_checked(self): + path = _make_skill( + self.tmpdir, "desc", + 'skill: desc\ndescription: "Uses {{UNDEFINED}}"', + "Body is fine." + ) + errors = sync.validate_placeholders([path], {}) + self.assertEqual(len(errors), 1) + self.assertIn("UNDEFINED", errors[0]) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# MAIN / CLI INTEGRATION +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestMainCLI(unittest.TestCase): + """Test main() behavior via subprocess or sys.exit interception.""" + + def setUp(self): + self._original_cwd = os.getcwd() + + def tearDown(self): + os.chdir(self._original_cwd) + + def _chdir_to_project(self): + """Change to the project root if .codecannon.yaml exists, else skip.""" + if not (REPO_ROOT / ".codecannon.yaml").exists(): + self.skipTest(".codecannon.yaml not found in repo root") + os.chdir(REPO_ROOT) + + def test_missing_config_exits_1(self): + with tempfile.TemporaryDirectory() as tmpdir: + with patch("sys.argv", ["sync.py", "--config", os.path.join(tmpdir, "nope.yaml")]): + with self.assertRaises(SystemExit) as ctx: + sync.main() + self.assertEqual(ctx.exception.code, 1) + + def test_validate_with_real_config(self): + """Running --validate against the real project config should pass.""" + self._chdir_to_project() + with patch("sys.argv", ["sync.py", "--validate"]): + sync.main() + + def test_dry_run_with_real_config(self): + """Running --dry-run against the real project should exit 0 (no drift) or 1 (drift).""" + self._chdir_to_project() + with patch("sys.argv", ["sync.py", "--dry-run"]): + try: + sync.main() + except SystemExit as e: + self.assertIn(e.code, (None, 0, 1)) + + def test_no_adapters_exits_1(self): + with tempfile.TemporaryDirectory() as tmpdir: + cfg = Path(tmpdir) / "empty.yaml" + cfg.write_text("config:\n FOO: bar\n") + os.chdir(tmpdir) + with patch("sys.argv", ["sync.py", "--config", str(cfg)]): + with self.assertRaises(SystemExit) as ctx: + sync.main() + self.assertEqual(ctx.exception.code, 1) + + def test_skill_filter(self): + """--skill flag should restrict which skills are synced.""" + self._chdir_to_project() + with patch("sys.argv", ["sync.py", "--dry-run", "--skill", "checkpoint"]): + try: + sync.main() + except SystemExit: + pass # acceptable + + def test_nonexistent_skill_filter_exits_1(self): + self._chdir_to_project() + with patch("sys.argv", ["sync.py", "--dry-run", "--skill", "nonexistent_skill_xyz"]): + with self.assertRaises(SystemExit) as ctx: + sync.main() + self.assertEqual(ctx.exception.code, 1) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# GOLDEN-FILE SNAPSHOT TESTS +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestGoldenFileSnapshots(unittest.TestCase): + """Verify that sync output for each adapter matches what's already on disk. + + This is a regression test: if a skill template or the sync engine changes, + the on-disk output should also be updated (via ./sync.py). If it drifts, + these tests catch it — same as the CI dry-run check, but at unittest level. + + Uses sync_skill to render into a temp directory, then compares against + the on-disk files. This avoids duplicating the rendering pipeline. + """ + + def test_all_generated_files_are_current(self): + """Every generated file on disk should match what sync would produce today.""" + import shutil + + project_root = REPO_ROOT + config_path = project_root / ".codecannon.yaml" + if not config_path.exists(): + self.skipTest(".codecannon.yaml not found") + + raw_config = sync.parse_yaml_simple(config_path.read_text()) + adapters_list = raw_config.get("adapters", []) + project_config = raw_config.get("config", {}) + project_config.setdefault("TICKET_LABEL_CREATION_ALLOWED", "false") + + skills_dir = REPO_ROOT / "skills" + skill_files = sorted(skills_dir.glob("*.md")) + args = _make_args() + + tmpdir = Path(tempfile.mkdtemp()) + try: + stale = [] + for adapter_name in adapters_list: + adapter = sync.load_adapter(adapter_name) + if not adapter: + continue + for skill_path in skill_files: + # Render via sync_skill into the temp directory + sync.sync_skill(skill_path, adapter, project_config, tmpdir, args) + + # Determine the output path (mirrors sync_skill logic) + raw = skill_path.read_text() + fm, _ = sync.parse_frontmatter(raw) + skill_name = fm.get("skill", skill_path.stem) + output_path_override = fm.get("output_path_override", "") + if output_path_override: + output_path_override = sync.apply_placeholders(output_path_override, project_config) + fresh_path = tmpdir / output_path_override + disk_path = project_root / output_path_override + else: + ext = adapter["output_extension"] + fresh_path = tmpdir / adapter["output_directory"] / f"{skill_name}{ext}" + disk_path = project_root / adapter["output_directory"] / f"{skill_name}{ext}" + + if not disk_path.exists(): + stale.append(f"{adapter_name}/{skill_name}: file missing at {disk_path}") + continue + + if not fresh_path.exists(): + continue # sync_skill decided not to write (shouldn't happen on fresh dir) + + if fresh_path.read_text() != disk_path.read_text(): + stale.append(f"{adapter_name}/{skill_name}: content differs from freshly rendered output") + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + if stale: + self.fail( + "Generated files are out of date. Run ./sync.py to regenerate.\n" + + "\n".join(f" - {s}" for s in stale) + ) + + +if __name__ == "__main__": + unittest.main()