
Swain Doctor
- 122 installs
- 2 repo stars
- Updated July 24, 2026
- cristoslc/swain
Diagnose broken or misconfigured Swain setups, surface configuration errors, and recommend fixes when agent sessions fail to start or complete.
About
swain-doctor from cristoslc/swain diagnoses problems in Swain agent environments. It checks configuration, explains why sessions or tasks fail, and proposes fixes so developers can restore reliable autonomous coding workflows instead of guessing at broken setup or runtime errors.
- Inspects Swain configuration health
- Surfaces session and workflow failure causes
- Suggests targeted remediation steps
- Reduces downtime in agent coding sessions
Swain Doctor by the numbers
- 122 all-time installs (skills.sh)
- Ranked #225 of 596 Debugging skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cristoslc/swain --skill swain-doctorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 122 |
|---|---|
| repo stars | ★ 2 |
| Last updated | July 24, 2026 |
| Repository | cristoslc/swain ↗ |
What it does
Diagnose broken or misconfigured Swain setups, surface configuration errors, and recommend fixes when agent sessions fail to start or complete.
Files
<!-- swain-model-hint: sonnet, effort: low -->
Doctor
Session-start health checks for swain projects. The consolidated script is authoritative for all detection. This skill file defines how to run the script and how to remediate each check.
Running the script
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
bash "$REPO_ROOT/.agents/bin/swain-doctor.sh"The script outputs structured JSON with { checks: [...], summary: {...} }. Each check has name, status, message, and optional detail. Parse it and present the summary table to the operator. Then use the remediation sections below only for checks that reported `warning` or `advisory` status — do not re-run detection.
To auto-fix flat-file artifacts: bash "$REPO_ROOT/.agents/bin/swain-doctor.sh" --fix-flat-artifacts
If the script is unavailable (e.g., .agents/bin/ symlinks not yet bootstrapped), fall back to running checks from the script source at skills/swain-doctor/scripts/swain-doctor.sh. Run checks sequentially (one Bash call at a time), never in parallel — parallel tool calls cascade-cancel on first error.
Preflight integration
A lightweight shell script ($REPO_ROOT/.agents/bin/swain-preflight.sh) performs quick checks before invoking the full doctor. If preflight exits 0, swain-doctor is skipped for the session. If it exits 1, swain-doctor runs normally.
When invoked directly by the user (not via auto-invoke), swain-doctor always runs regardless of preflight status.
Governance content reference
The canonical governance rules live in references/AGENTS.content.md. Both swain-doctor and swain-init read from this single source of truth. If the upstream rules change in a future swain release, update that file and bump the skill version.
---
Remediation by check name
Each section below corresponds to a check name emitted by the script. Only consult the relevant section when the check status is warning or advisory.
governance
Injection (missing): Read references/governance-injection.md for Claude Code and Cursor injection procedures. Source: references/AGENTS.content.md.
Replacement (stale): The script auto-repairs stale governance blocks when both markers (<!-- swain governance --> and <!-- end swain governance -->) are present. If auto-repair fails (markers missing), read references/governance-injection.md § Stale governance replacement for manual replacement.
legacy_skills
Clean up renamed and retired skill directories using fingerprint checks. Read references/legacy-cleanup.md for the full procedure. Data source: references/legacy-skills.json.
agents_directory
Create .agents/ with mkdir -p .agents. This directory is used by swain-do for configuration and by swain-design scripts for logs.
tickets
Validates .tickets/ health — YAML frontmatter, stale locks. Read references/tickets-validation.md for repair procedures.
beads_migration
Auto-migrates .beads/ → .tickets/ if present. Read references/beads-migration.md for the migration procedure.
tools
Required tools: git, jq. Optional: tk, uv, gh, tmux, fswatch. Never install automatically. Read references/tool-availability.md for degradation notes and install instructions.
settings
If swain.settings.json is missing, create it with default content. If it contains invalid JSON, fix the syntax. User settings live at ${XDG_CONFIG_HOME:-$HOME/.config}/swain/settings.json.
script_permissions
The script auto-repairs missing execute permissions on .sh and .py files in the skill tree. No manual remediation needed — advisory status means it already fixed them.
memory_directory
The script auto-creates the memory directory if missing. If creation fails, manually create:
mkdir -p "$HOME/.claude/projects/$(echo "$REPO_ROOT" | tr '/' '-')/memory"superpowers
When status is warning (missing or partial), ask the operator:
Superpowers (obra/superpowers) is not installed [or: partially installed]. It provides TDD, brainstorming, plan writing, and verification skills that swain chains into.>
Install superpowers now? (yes/no)
If yes: npx skills add obra/superpowers. If no, note "Superpowers: skipped" and continue.
epics_initiative
Non-blocking advisory. Report the count and suggest:
N Epic(s) haveparent-visionbut noparent-initiative. Addingparent-initiativelinks is optional but recommended. To run the guided migration, ask: "run the initiative migration."
Read references/initiative-migration.md for the full 6-step guided migration workflow.
readme
Report: README.md missing — swain alignment loop has no public intent anchor. Run swain-init to seed one.
artifact_indexes
The script auto-repairs stale indexes via rebuild-index.sh. If the rebuild script is unavailable or a rebuild fails, check that .agents/bin/rebuild-index.sh exists and is executable. Re-run swain-doctor after fixing the symlink.
evidence_pools
If docs/evidence-pools/ exists, run the trove migration:
bash "$REPO_ROOT/.agents/bin/migrate-to-troves.sh" --dry-run # preview
bash "$REPO_ROOT/.agents/bin/migrate-to-troves.sh" # migrateworktrees
Stale worktrees (branch already merged into HEAD) can be pruned: git worktree remove <path>. Orphaned worktrees (directory missing) can be pruned: git worktree prune. Stale lockfiles and unclaimed worktrees are reported in the detail field. Read references/worktree-detection.md for classification rules.
worktree_context
Validates the current session's worktree, not all linked worktrees (that's worktrees). All four sub-checks auto-fix deterministically — warnings mean auto-fix failed, advisory means auto-fix succeeded.
Location outside .worktrees/ (auto-move): ADR-034 mandates .worktrees/ as the canonical location. The script auto-moves the worktree via git worktree move <path> <main_root>/.worktrees/<branch>. Failure (warning) means the target path already exists or git worktree move failed — resolve manually.
Missing lockfile (auto-create): The script auto-creates a lockfile at .agents/worktrees/<branch>.lock using swain-lockfile.sh claim, or falls back to writing the lockfile directly. On collision (existing lockfile for same branch), a PID-suffixed lockfile is created. Advisory = auto-created; warning = creation failed.
Branch name violates ADR-025 (auto-rename): The script auto-renames the branch and moves the worktree folder to match ADR-025 naming. It uses swain-worktree-name.sh when a purpose is available, or falls back to session-<timestamp>. The lockfile is also renamed. Advisory = renamed; warning = rename failed.
Folder name != branch name (auto-move): The script auto-moves the worktree folder so basename matches the branch name via git worktree move. Advisory = moved; warning = move failed (target already exists).
lifecycle_dirs
Old phase directories from before ADR-003's three-track normalization. Read references/lifecycle-migration.md for detection commands, remediation steps, and the migration script.
tk_health
If vendored tk is not found or not executable, try: /swain update to reinstall skills. The expected path is <skills-root>/swain-do/bin/tk.
operator_bin_symlinks
The script auto-repairs missing or stale bin/ symlinks. Conflicts (real file exists at bin/<name>) require manual resolution — rename or remove the conflicting file, then re-run doctor.
commit_signing
The script auto-enables commit signing when a signing key is detected at ~/.ssh/swain_signing. If no key exists, run /swain-keys to provision one.
ssh_readiness
Runs scripts/ssh-readiness.sh --check. Issues are reported in the detail field. Common fixes: verify ~/.ssh/config.d/ includes the project-specific SSH alias, check key permissions (chmod 600), ensure the key is added to the remote host.
crash_debris
The script auto-removes stale .git/index.lock files. Other crash debris (orphaned temp files, partial merges) is reported for manual cleanup. Review the detail field for specific file paths and remove them if safe.
agents_bin_symlinks
The script auto-repairs missing and stale .agents/bin/ symlinks. Broken symlinks are removed. Conflicts (real files) are reported for manual resolution.
flat_artifacts
Flat-file artifacts sit directly in phase directories instead of their own folders. Run with --fix-flat-artifacts to auto-migrate: bash "$REPO_ROOT/.agents/bin/swain-doctor.sh" --fix-flat-artifacts. Each artifact gets a folder named (<ID>)-<Title>/.
swain_symlink
If bin/swain symlink is missing but the script exists at <skills-root>/swain/scripts/swain, create it:
ln -sf "$(python3 -c "import os,sys; print(os.path.relpath(sys.argv[1], sys.argv[2]))" "$SKILLS_ROOT/swain/scripts/swain" "$REPO_ROOT/bin")" "$REPO_ROOT/bin/swain"If the symlink is broken (target missing), the swain skill may need reinstalling: /swain update.
branch_model
Advisory — swain recommends a trunk+release branch model (ADR-013). trunk is the development branch; release is the distribution branch updated via squash-merge at release time. To adopt it, run .agents/bin/migrate-to-trunk-release.sh (or --dry-run to preview). This is optional — swain works with any branch model, but sync and release features assume trunk+release when configured.
platform_dotfolders
Remove dotfolder stubs for uninstalled agent platforms. Read references/platform-cleanup.md for the detection and cleanup procedure. Requires jq. The script reports which dotfolders are orphaned — verify they contain only installer symlinks before removing.
skill_gitignore
Vendored swain skill folders should be gitignored in consumer projects. Read references/gitignore-skill-folders.md for the remediation procedure. Append these entries to .gitignore:
.claude/skills/swain/
.claude/skills/swain-*/
.agents/skills/swain/
.agents/skills/swain-*/Skipped automatically when running in the swain source repo.
---
Summary report
After all checks complete, output a concise summary table:
swain-doctor summary:
Governance ......... ok
Legacy cleanup ..... ok (nothing to clean)
Platform dotfolders ok (nothing to clean)
.agents directory .. ok
.tickets/ .......... ok
Stale .beads/ ...... ok (not present)
Tools .............. ok (1 optional missing: fswatch)
Settings ........... ok
Script perms ....... ok
Memory directory ... ok
Superpowers ........ ok (6/6 skills detected)
Epics w/o initiative advisory (3 epics — see note below)
README ............. ok
Artifact indexes ... ok
Evidence pools ..... ok
Worktrees .......... ok
Worktree context ... ok
Lifecycle dirs ..... ok
tk health .......... ok
Operator bin/ ...... ok
Commit signing ..... ok
SSH readiness ...... ok
Crash debris ....... ok
.agents/bin/ ....... ok
Flat artifacts ..... ok
swain symlink ...... ok
Branch model ....... ok
Skill gitignore .... ok
3 checks performed repairs. 0 issues remain.Use these status values:
- ok — nothing to do.
- advisory — auto-repaired or informational (give specifics).
- warning — issue found, user action recommended (give specifics).
- skipped — check could not run (e.g., jq missing for JSON validation).
If any checks have warnings, list them below the table with remediation steps from the sections above.
Read [PURPOSE.md](../../PURPOSE.md) for this project's identity, worldview, and foundational principles.
<!-- swain governance — do not edit this block manually -->
Swain
Swain makes agentic development safe, aligned, and sustainable for a solo developer. Its architecture rests on the Intent -> Execution -> Evidence -> Reconciliation loop — decide what to build, do the work, capture what happened, verify alignment. Artifacts on disk — specs, epics, spikes, ADRs — live under docs/ and encode what was decided, what to build, and what constraints apply. Read them before acting. When they're ambiguous, ask the operator (the human developer) rather than guessing. When artifacts conflict with each other, ask the operator.
Your job is to stay aligned with the artifacts. The operator's job is to make decisions and evolve them.
Skill routing
| Intent | Skill |
|---|---|
| Create, plan, update, transition, or review any artifact (Vision, Initiative, Journey, Epic, Spec, Spike, ADR, Persona, Runbook, Design) | swain-design |
| Project status, roadmap, "what's next?", dashboard | swain-roadmap |
| Task tracking, execution progress, implementation plans, bookmarks, decisions | swain-do |
| Session start, focus lane, onboarding | swain-init |
| Session end, teardown, cleanup, merge worktrees | swain-teardown |
This project uses tk (ticket) for ALL task tracking. Do NOT use markdown TODOs or built-in task systems.
Work hierarchy
Vision → Initiative → Epic → SpecStandalone specs can attach directly to an initiative for small work without needing an epic wrapper.
Worktree isolation
Implementation work happens in a worktree. Code changes, new features, multi-file refactors, and non-trivial artifact creation require worktree isolation. swain-do's worktree preamble handles creation; follow it before starting implementation work.
Lightweight operations land directly on trunk. Artifact phase transitions, frontmatter metadata updates, single-file edits, and index refreshes are low-risk and trivially reversible — they do not need worktree isolation. Read-only investigation (git log, reading files, checking state) is also fine on trunk.
Superpowers skill chaining
When superpowers skills are installed (.agents/skills/ or .claude/skills/), swain skills must chain into them at defined integration points. Each swain skill documents its specific chains — the principle is: brainstorming before creative work, writing-plans before implementation, test-driven-development during implementation, and verification-before-completion before any success claim.
If superpowers is not installed, these chains are skipped, not blocked. Swain-to-swain chains always apply: plan completion triggers SPEC transition, all child SPECs complete triggers EPIC transition, and EPIC terminal state triggers a retrospective.
Skill change discipline
Skill changes are code changes. Skill files (skills/, .claude/skills/, .agents/skills/) are code written in markdown syntax. Non-trivial skill edits require worktree isolation — the same discipline applied to .sh, .py, and other code files. Trivial fixes (typo corrections, single-line doc fixes, ≤5-line diffs touching one file with no structural changes) may land directly on trunk.
Readability
All artifacts produced by swain skills must meet a Flesch-Kincaid grade level of 10 or below on prose content. After writing or editing an artifact, run readability-check.sh on it. If the score exceeds the threshold, revise the prose — use shorter sentences, simpler words, and active voice — then re-check. Do not rewrite content that already passes. If three revision attempts still fail, note the score in the commit message and proceed.
End every bulleted and numbered list item with a period. The readability checker treats un-terminated bullets as a single run-on sentence, which inflates the grade level.
Session startup
Session initialization is handled by the swain shell launcher, which invokes /swain-init as the initial prompt. If a session starts without the launcher, the operator can manually run /swain-init.
Bug reporting
When you encounter a bug in swain itself, report it upstream at cristoslc/swain using gh issue create. Local patches are fine — but the upstream issue ensures tracking.
Artifact identification
Always reference artifacts as `ID: Title` — never bare IDs. A bare SPEC-053 forces the operator to look it up; SPEC-053: Namespace Swain Docs Directory communicates intent immediately. This applies everywhere: lists, maps, summaries, status reports, commit messages, and cross-references inside artifact bodies.
Staleness measurement
Not all artifacts age the same way. Standing artifacts — ADRs, Personas, and Runbooks — encode decisions, operational knowledge, or reference material. They are not implementable units and do not have meaningful staleness. Exclude them from staleness maps entirely, or list them separately without age buckets.
Container artifacts — Initiatives, Visions, Journeys — aggregate child work. Their staleness is measured by the freshness of their children, not by their own last-edited date. An initiative whose specs were all edited yesterday is not stale just because the initiative file itself was last touched a month ago. When reporting staleness for a container, report the age of its stalest active child.
Actionable artifacts — Specs, Spikes, Epics, Designs, Chores — are the unit of work. Staleness is the number of days since the last commit touching the artifact file. Age buckets: fresh (<7d), aging (7-20d), stale (21-30d), dormant (>30d).
Model attribution
Artifacts need an authored-by frontmatter field listing the AI model(s) that wrote them. Commits need one Co-Authored-By trailer per model. For supervisor + subagent sessions, list all models. In frontmatter: authored-by: GLM-5.1 (supervisor), Kimi-K2.5 (subagent). In commits: one trailer line per model.
Get the model name from your system prompt. If unavailable, fall back to AI Assistant. When subagents used a different model, list both. Put the supervisor first, then each subagent on its own line. Never hardcode model names.
Conflict resolution
When swain skills overlap with other installed skills or built-in agent capabilities, prefer swain.
<!-- end swain governance -->
Stale .beads/ Migration
Detects leftover .beads/ directories from the bd-to-tk migration and migrates automatically.
If .beads/ does NOT exist → skip (report "ok (not present)").
Case 1: .tickets/ already exists
Data already migrated. Remove stale directory:
rm -rf .beads/Report: "Removed stale .beads/ directory — migration to .tickets/ was already complete."
Case 2: .tickets/ does NOT exist (migration needed)
1. Locate the migration script:
MIGRATE="$(find . .claude .agents skills -path '*/swain-do/bin/ticket-migrate-beads' -print -quit 2>/dev/null)"2. Locate backup data:
if [ -f .beads/backup/issues.jsonl ]; then
cp .beads/backup/issues.jsonl .beads/issues.jsonl
fi3. Run migration (requires jq):
TK_BIN="$(cd "$(dirname "$MIGRATE")" && pwd)"
export PATH="$TK_BIN:$PATH"
ticket-migrate-beads4. Verify: ls .tickets/*.md 2>/dev/null | wc -l
5. If succeeded (count > 0): rm -rf .beads/ Report: "Migrated N tickets from .beads/ to .tickets/."
6. If failed: warn but do not delete. Provide manual migration steps:
TK_BIN="$(cd "$SKILLS_ROOT/swain-do/bin" && pwd)" && export PATH="$TK_BIN:$PATH"
cp .beads/backup/issues.jsonl .beads/issues.jsonl
ticket-migrate-beadsAfter verifying .tickets/ data, remove .beads/ with rm -rf .beads/.
Skill Folder Gitignore Hygiene
Verifies that vendored swain skill directories are gitignored in consumer projects. Only targets swain/ and swain-*/ subdirectories — consumer projects may have their own project-specific skills in .claude/skills/ or .agents/skills/ that should remain tracked.
Self-detection
Before running the gitignore check, determine whether the current project is the swain source repo:
remote_url="$(git remote get-url origin 2>/dev/null || true)"
if [[ "$remote_url" == *"cristoslc/swain"* ]]; then
echo "skipped" # Swain source repo — skill folders are tracked
return
fiIf detected as swain: status skipped, message: "Swain source repo — skill folders are tracked."
Detection
Enumerate vendored swain skill directories that exist on disk and check whether each is covered by .gitignore rules:
missing=()
for base in .claude/skills .agents/skills; do
[ -d "$base" ] || continue
for dir in "$base"/swain "$base"/swain-*/; do
[ -d "$dir" ] || continue
if ! git check-ignore -q "$dir" 2>/dev/null; then
missing+=("$dir")
fi
done
donegit check-ignore -q respects nested .gitignore files and global gitignore config — no string matching on .gitignore content.
Status values
- ok — all vendored swain skill directories are gitignored (or none exist on disk)
- warning — one or more vendored swain skill directories exist but are not gitignored
- skipped — swain source repo detected; skill folders are intentionally tracked
Remediation
When missing is non-empty, offer to append entries to the project's root .gitignore:
gitignore_entries="
# Vendored swain skills (managed by swain-update)
.claude/skills/swain/
.claude/skills/swain-*/
.agents/skills/swain/
.agents/skills/swain-*/
"If .gitignore doesn't exist, create it. If it exists, append the missing entries (with a blank line separator).
Remediation message
Vendored swain skill folder(s) not gitignored: {list}. These contain vendored skill dependencies and should not be committed to your repository.
>
Add gitignore entries? (yes/no)
On yes: append entries and report repaired. On no: report warning and continue.
Governance Injection
When governance rules are not found (or were deleted during legacy cleanup), inject them into the appropriate context file.
Claude Code
Determine the target file:
1. If CLAUDE.md exists and its content is just @AGENTS.md (the include pattern set up by swain-init), inject into AGENTS.md instead. 2. Otherwise, inject into CLAUDE.md (create it if it doesn't exist).
Read the canonical governance content from references/AGENTS.content.md and append it to the target file.
Cursor
Write the governance rules to .cursor/rules/swain-governance.mdc. Create the directory if needed.
Prepend Cursor MDC frontmatter to the canonical content from references/AGENTS.content.md:
---
description: "swain governance — skill routing, pre-implementation protocol, issue tracking"
globs:
alwaysApply: true
---Then append the full contents of references/AGENTS.content.md after the frontmatter.
After injection
Tell the user:
Governance rules installed in<file>. These ensure swain-design, swain-do, and swain-release skills are routable. You can customize the rules — just keep the<!-- swain governance -->markers so this skill can detect them on future sessions.
Stale governance replacement
When swain-doctor's freshness check detects that the installed governance block differs from the canonical source (references/AGENTS.content.md), replace the stale block:
1. Identify the target file — the file containing the <!-- swain governance marker (from the freshness check).
2. Warn about local edits — check whether the installed block contains content not present in the canonical source. If so, warn:
Your governance block contains local modifications not in the canonical source. These will be overwritten. Move project-specific rules outside the <!-- swain governance --> markers before proceeding, or they will be lost.If the user declines, report status as stale (deferred) and continue.
3. Replace the block — delete everything from the <!-- swain governance marker line through the <!-- end swain governance --> marker line (inclusive), then insert the full contents of references/AGENTS.content.md at the same position. Preserve any content before and after the markers.
4. Report:
Governance block updated in<file>. The block was stale — it has been replaced with the current canonical version fromAGENTS.content.md.
Epics Without Parent-Initiative — Guided Migration
When the operator asks to run the migration (or says "how do I fix the initiative migration?"), guide them through these steps:
Step 1: Scan and group
Run the scan helper to list all epics without parent-initiative, grouped by parent-vision:
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
SCAN_SCRIPT="$(find "$REPO_ROOT" -path '*/swain-doctor/scripts/swain-initiative-scan.sh' -print -quit 2>/dev/null)"
[ -n "$SCAN_SCRIPT" ] && bash "$SCAN_SCRIPT" || echo "swain-initiative-scan.sh not found"Analyze the output and propose initiative clusters. For example:
"Under VISION-001, you have 8 epics. I'd suggest grouping them into 2-3 initiatives based on theme:
- Security Hardening: EPIC-017, EPIC-023 (both security-related)
- Developer Experience: EPIC-016, EPIC-019, EPIC-022 (workflow improvements)
- Product Design: EPIC-021 (standalone strategic bet)
>
Does this grouping work, or would you like to adjust?"
Proposals are suggestions, not commitments. Base clustering on epic titles, descriptions, and shared themes visible in the scan output.
Step 2: Operator decides
The operator approves, adjusts, or rejects each proposed cluster. This is a vision-mode decision — don't rush it. Present one vision's worth of clusters at a time if there are many.
Step 3: Create initiatives
For each approved cluster, invoke swain-design to create an Initiative artifact:
- Set
parent-visionto the vision these epics belong to - Set
priority-weightif the operator specifies one (otherwise omit — it inherits from the vision) - List the child epics in the "Child Epics" section of the initiative document
Step 4: Re-parent epics
For each epic in an approved cluster, add parent-initiative: INITIATIVE-NNN to its frontmatter. During the migration period, parent-vision can remain alongside parent-initiative — specgraph accepts both and resolves the vision ancestor through whichever path exists.
# Before
parent-vision: VISION-001
# After (during migration — both fields coexist)
parent-vision: VISION-001
parent-initiative: INITIATIVE-001Step 5: Set vision weights
Prompt the operator to set priority-weight on their visions if not already set:
priority-weight: high # active strategic focus
priority-weight: medium # maintained, progressing (default if omitted)
priority-weight: low # parked, not abandonedThey can defer — everything defaults to medium and the system works without weights.
Step 6: Verify
Run specgraph to verify the new hierarchy looks correct:
bash "$(find "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -path '*/swain-design/scripts/chart.sh' -print -quit 2>/dev/null)"
bash "$(find "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -path '*/swain-design/scripts/chart.sh' -print -quit 2>/dev/null)" recommendCheck that initiatives appear in the tree and that recommendations reflect the new structure.
Migration is incremental. The operator can migrate one vision's epics at a time. Unmigrated epics continue to work — they just show this advisory on each session start.
Legacy Skill Cleanup
Clean up skill directories that have been superseded by renames or retired entirely. Read the legacy mapping from references/legacy-skills.json.
Renamed skills
For each entry in the renamed map, check both .claude/skills/ and .agents/skills/:
1. Check whether <skills-root>/<old-name>/ exists. 2. If it does NOT exist, skip (nothing to clean). 3. If it exists, check whether <skills-root>/<new-name>/ also exists. If the replacement is missing, skip and warn — the update may not have completed:
Skipping cleanup of<old-name>— its replacement<new-name>is not installed.
4. If both exist, fingerprint check: read <skills-root>/<old-name>/SKILL.md and check whether its content matches ANY of the fingerprints listed in legacy-skills.json. Specifically, grep the file for each fingerprint string — if at least one matches, the skill is confirmed to be a swain skill. 5. If no fingerprint matches, skip and warn — this may be a third-party skill with the same name:
Skipping cleanup of <skills-root>/<old-name>/ — it does not appear to be a swain skill (no fingerprint match). If this is a stale swain skill, delete it manually.6. If fingerprint matches and replacement exists, delete the old directory:
rm -rf <skills-root>/<old-name>Tell the user:
Removed legacy skill<skills-root>/<old-name>/(replaced by<new-name>).
Retired skills
For each entry in the retired map (pre-swain skills absorbed into the ecosystem), check both .claude/skills/ and .agents/skills/:
1. Check whether <skills-root>/<old-name>/ exists. 2. If it does NOT exist, skip (nothing to clean). 3. If it exists, fingerprint check: same as for renamed skills — read <skills-root>/<old-name>/SKILL.md and check whether its content matches ANY fingerprint in legacy-skills.json. 4. If no fingerprint matches, skip and warn:
Skipping cleanup of <skills-root>/<old-name>/ — it does not appear to be a known pre-swain skill (no fingerprint match). Delete manually if stale.5. If fingerprint matches, delete the old directory:
rm -rf <skills-root>/<old-name>Tell the user:
Removed retired pre-swain skill<skills-root>/<old-name>/(functionality now in<absorbed-by>).
After processing all entries, check whether the governance block in the context file references old skill names. If the governance block (between <!-- swain governance --> and <!-- end swain governance -->) contains any old-name from the renamed map, delete the entire block (inclusive of markers) and proceed to Governance injection to re-inject a fresh copy with current names.
#!/usr/bin/env bash
# legacy-skills-lib.sh — shared helpers for stale swain skill detection
legacy_skills_json_path() {
echo "$SKILL_DIR/references/legacy-skills.json"
}
legacy_skill_entries() {
local json_path="${1:-$(legacy_skills_json_path)}"
python3 - "$json_path" <<'PY'
import json
import sys
with open(sys.argv[1], "r", encoding="utf-8") as handle:
data = json.load(handle)
for kind in ("renamed", "retired"):
for old_name, replacement in data.get(kind, {}).items():
print(f"{kind}\t{old_name}\t{replacement}")
PY
}
legacy_skill_fingerprints() {
local json_path="${1:-$(legacy_skills_json_path)}"
python3 - "$json_path" <<'PY'
import json
import sys
with open(sys.argv[1], "r", encoding="utf-8") as handle:
data = json.load(handle)
for fingerprint in data.get("fingerprints", []):
print(fingerprint)
PY
}
legacy_skill_matches_fingerprint() {
local skill_dir="$1"
local json_path="${2:-$(legacy_skills_json_path)}"
local skill_file="$skill_dir/SKILL.md"
[[ -f "$skill_file" ]] || return 1
while IFS= read -r fingerprint; do
[[ -n "$fingerprint" ]] || continue
if grep -F -q -- "$fingerprint" "$skill_file"; then
return 0
fi
done < <(legacy_skill_fingerprints "$json_path")
return 1
}
{
"_comment": "Maps old skill directory names to their current replacements. Used by swain-doctor and swain-update to clean up renamed skills. Only remove an old skill if its replacement exists AND the old skill passes the fingerprint check (see SKILL.md).",
"renamed": {
"governance": "swain-config",
"swain-config": "swain-doctor",
"spec-management": "swain-design",
"execution-tracking": "swain-do",
"release": "swain-release"
},
"retired": {
"update-agents-core": "swain-update",
"skill-manager": "swain-update",
"remote-skill-manager": "swain-update",
"swain-dispatch": "deprecated (requires API billing; use parallel agents in-session)",
"swain-status": "swain-session"
},
"fingerprints": [
"source: swain",
"author: cristos",
"swain governance",
"swain-design",
"swain-do",
"agents-core",
"skill-manager",
"swain-dispatch"
]
}
Lifecycle Directory Migration
Detect old phase directories from before ADR-003's three-track normalization. Old directory names: Draft/, Planned/, Review/, Approved/, Testing/, Implemented/, Adopted/, Deprecated/, Archived/, Sunset/, Validated/.
Detection
OLD_PHASES="Draft Planned Review Approved Testing Implemented Adopted Deprecated Archived Sunset Validated"
for dir in docs/*/; do
for phase in $OLD_PHASES; do
if [[ -d "${dir}${phase}" ]]; then
# Check for non-empty (ignore hidden files)
if find "${dir}${phase}" -maxdepth 1 -not -name '.*' -print -quit 2>/dev/null | grep -q .; then
echo " Old directory: ${dir}${phase}"
fi
fi
done
doneRemediation
1. List each old directory and its artifact count. 2. Explain: "ADR-003 normalized artifact lifecycle phases into three tracks. Old phase directories need migration." 3. Check for the migration script: $(find "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -path '*/swain-design/scripts/migrate-lifecycle-dirs.py' -print -quit 2>/dev/null)
- If available: offer to run
uv run python3 "$(find "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -path '*/swain-design/scripts/migrate-lifecycle-dirs.py' -print -quit 2>/dev/null)" --dry-runfirst, then the real migration. - If unavailable: provide manual
git mvinstructions using the phase mapping from ADR-003.
4. After migration, clean up empty old directories.
Status values
- ok — no old directories found
- repaired — migration script ran successfully
- warning — old directories found, user chose not to migrate now
Platform Dotfolder Cleanup
The npx skills add --all command creates dotfolder stubs (e.g., .windsurf/, .cursor/) for agent platforms that are not installed. These directories only contain symlinks back to .agents/skills/ and clutter the working tree.
Read platform data from references/platform-dotfolders.json. Each entry has a project_dotfolder name and one or both detection strategies: command (CLI binary name) and detection (HOME config directory path).
Step 1 — Autodetect installed platforms
Iterate over the platforms array. A platform is installed if either check succeeds:
1. If the entry has a command field → command -v <command> &>/dev/null 2. If the entry has a detection field → expand the path (replace ~ with $HOME, evaluate env var defaults) and check whether the directory exists.
Always consider .claude installed (never a cleanup candidate).
installed_dotfolders=(".claude")
while IFS= read -r entry; do
dotfolder=$(echo "$entry" | jq -r '.project_dotfolder')
cmd=$(echo "$entry" | jq -r '.command // empty')
det=$(echo "$entry" | jq -r '.detection // empty')
found=false
if [[ -n "$cmd" ]] && command -v "$cmd" &>/dev/null; then
found=true
fi
if [[ -n "$det" ]] && ! $found; then
det_expanded=$(echo "$det" | sed "s|~|$HOME|g")
det_expanded=$(eval echo "$det_expanded" 2>/dev/null)
[[ -d "$det_expanded" ]] && found=true
fi
$found && installed_dotfolders+=("$dotfolder")
done < <(jq -c '.platforms[]' "SKILL_DIR/references/platform-dotfolders.json")(Replace `SKILL_DIR` with the actual path to this skill's directory.)
Requires: jq. If unavailable, skip and warn.
Step 2 — Build cleanup candidates
Every platform entry whose project_dotfolder is NOT in installed_dotfolders is a candidate.
Step 3 — Remove installer stubs
For each candidate:
1. Check whether the directory exists in the project root. Skip if not. 2. Verify it is installer-generated — should contain only a skills/ subdirectory:
entries=$(ls -A "<dotfolder>" 2>/dev/null | wc -l)
if [[ "$entries" -le 1 ]] && [[ -d "<dotfolder>/skills" || "$entries" -eq 0 ]]; then
rm -rf <dotfolder> # Safe — installer stub
fi3. If the directory contains other content → skip and warn: "contains user content beyond installer symlinks." 4. Report: "Removed N platform dotfolder(s) created by npx skills add."
{
"_comment": "Platform dotfolders that may be auto-created by `npx skills add`. Each entry has a detection strategy: `command` (CLI binary on PATH) and/or `detection` (HOME config directory). A platform is installed if either check succeeds. Entries with collision-prone command names (cmd, cortex, mux, pi, vibe) omit `command` and use HOME detection only. `.claude` is always kept — never listed here. See GitHub issue #21.",
"platforms": [
{ "project_dotfolder": ".cursor", "command": "cursor", "detection": "~/.cursor" },
{ "project_dotfolder": ".windsurf", "command": "windsurf", "detection": "~/.codeium/windsurf" },
{ "project_dotfolder": ".codex", "command": "codex", "detection": "${CODEX_HOME:-~/.codex}" },
{ "project_dotfolder": ".cline", "detection": "~/.cline" },
{ "project_dotfolder": ".continue", "detection": "~/.continue" },
{ "project_dotfolder": ".augment", "detection": "~/.augment" },
{ "project_dotfolder": ".goose", "command": "goose", "detection": "${XDG_CONFIG_HOME:-~/.config}/goose" },
{ "project_dotfolder": ".roo", "detection": "~/.roo" },
{ "project_dotfolder": ".gemini", "command": "gemini", "detection": "~/.gemini" },
{ "project_dotfolder": ".copilot", "detection": "~/.copilot" },
{ "project_dotfolder": ".amp", "command": "amp", "detection": "${XDG_CONFIG_HOME:-~/.config}/amp" },
{ "project_dotfolder": ".opencode", "command": "opencode", "detection": "${XDG_CONFIG_HOME:-~/.config}/opencode" },
{ "project_dotfolder": ".kiro", "detection": "~/.kiro" },
{ "project_dotfolder": ".adal", "command": "adal", "detection": "~/.adal" },
{ "project_dotfolder": ".codebuddy", "command": "codebuddy", "detection": "~/.codebuddy" },
{ "project_dotfolder": ".commandcode", "detection": "~/.commandcode" },
{ "project_dotfolder": ".cortex", "detection": "~/.cortex" },
{ "project_dotfolder": ".crush", "command": "crush", "detection": "${XDG_CONFIG_HOME:-~/.config}/crush" },
{ "project_dotfolder": ".factory", "command": "droid", "detection": "~/.factory" },
{ "project_dotfolder": ".iflow", "command": "iflow", "detection": "~/.iflow" },
{ "project_dotfolder": ".junie", "command": "junie", "detection": "~/.junie" },
{ "project_dotfolder": ".kilocode", "command": "kilocode", "detection": "~/.kilocode" },
{ "project_dotfolder": ".kode", "command": "kode", "detection": "~/.kode" },
{ "project_dotfolder": ".mcpjam", "command": "mcpjam", "detection": "~/.mcpjam" },
{ "project_dotfolder": ".mux", "detection": "~/.mux" },
{ "project_dotfolder": ".neovate", "command": "neovate", "detection": "~/.neovate" },
{ "project_dotfolder": ".openhands", "command": "openhands", "detection": "~/.openhands" },
{ "project_dotfolder": ".pi", "detection": "~/.pi" },
{ "project_dotfolder": ".pochi", "command": "pochi", "detection": "~/.pochi" },
{ "project_dotfolder": ".qoder", "command": "qodercli", "detection": "~/.qoder" },
{ "project_dotfolder": ".qwen", "command": "qwen", "detection": "~/.qwen" },
{ "project_dotfolder": ".trae", "command": "trae", "detection": "~/.trae" },
{ "project_dotfolder": ".vibe", "detection": "~/.vibe" },
{ "project_dotfolder": ".zencoder", "detection": "~/.zencoder" }
]
}
Runtime Checks
Procedural checks for memory directory, settings, script permissions, .agents directory, status cache, and SSH alias readiness.
Memory directory
The Claude Code memory directory stores status-cache.json and stage-status.json. Skills that write to this directory will fail silently or error if it doesn't exist. Note: session.json is stored at .agents/session.json (per-project, gitignored) — not in the memory directory.
Step 1 — Compute the correct path
The directory slug is derived from the full absolute repo path, not just the project name:
REPO_ROOT="$(git rev-parse --show-toplevel)"
_PROJECT_SLUG=$(echo "$REPO_ROOT" | tr '/' '-')
MEMORY_DIR="$HOME/.claude/projects/${_PROJECT_SLUG}/memory"Step 2 — Create if missing
if [[ ! -d "$MEMORY_DIR" ]]; then
mkdir -p "$MEMORY_DIR"
fiIf created, tell the user:
Created memory directory at $MEMORY_DIR. This is where swain-session stores its caches.If it already exists, this step is silent.
Step 3 — Validate existing cache files
If the memory directory exists, check that any existing JSON files in it are valid:
for f in "$MEMORY_DIR"/*.json; do
[[ -f "$f" ]] || continue
if ! jq empty "$f" 2>/dev/null; then
echo "warning: $f is corrupt JSON — removing"
rm "$f"
fi
doneReport any files that were removed due to corruption. This prevents skills from reading garbage data.
Requires: jq (skip this step if jq is not available — warn instead).
Settings validation
Swain uses a two-tier settings model. Malformed JSON in either file causes silent failures across multiple skills (swain-session).
Check project settings
If swain.settings.json does not exist in the repo root, create it with an empty object:
if [[ ! -f swain.settings.json ]]; then
echo '{}' > swain.settings.json
fiIf created, report repaired:
Created swain.settings.json with empty defaults. All settings have built-in defaults.If swain.settings.json exists, validate it:
jq empty swain.settings.json 2>/dev/nullIf this fails, warn:
swain.settings.json contains invalid JSON. Skills will fall back to defaults. Fix the file or delete it to use defaults.Check user settings
If ${XDG_CONFIG_HOME:-$HOME/.config}/swain/settings.json exists:
jq empty "${XDG_CONFIG_HOME:-$HOME/.config}/swain/settings.json" 2>/dev/nullIf this fails, warn:
User settings file contains invalid JSON. Skills will fall back to project defaults. Fix the file or delete it.
Requires: jq (skip these checks if jq is not available).
Script permissions
All shell and Python scripts in the installed skill tree, usually .agents/skills/*/scripts/, must be executable. Skills invoke these via .agents/bin/ or by direct path, and Python helpers may be run directly, so the executable bit still matters.
Check and repair
find .agents/skills -type f \( -path '*/scripts/*.sh' -o -path '*/scripts/*.py' \) ! -perm -u+xIf any files are found without the executable bit:
chmod +x <files...>Tell the user:
Fixed executable permissions on N script(s).
If all scripts are already executable, this step is silent.
SSH alias readiness
Repos that ran swain-keys --provision switch origin to a project-specific SSH alias such as git@github.com-swain:owner/repo.git. The alias targets ssh.github.com:443 so it works in environments where GitHub SSH on port 22 is blocked. Sandboxes and fresh runtimes often have a different HOME, so the repo can point at an alias that does not exist locally even though git signing is configured.
Detection and repair
Use the helper:
bash scripts/ssh-readiness.sh --repairThe helper is a no-op for repos that do not use a github.com-<project> alias remote.
For alias remotes, it checks:
sshis available onPATH~/.ssh/configexists and includesconfig.d/*~/.ssh/config.d/<project>.confexists- the alias config defines
Host github.com-<project> - the alias
IdentityFileexists locally
Repair behavior
--repair only performs safe local fixes:
- creates
~/.ssh/and~/.ssh/config.d/ - creates or patches
~/.ssh/configto includeconfig.d/* - creates
~/.ssh/config.d/<project>.confwhen the default key~/.ssh/<project>_signingalready exists
It does not generate keys or install packages. If the key is missing, report:
SSH alias is configured but the local key is missing. Run swain-keys --provision in this runtime.If ssh is missing, warn and provide an install hint. Do not install automatically during doctor.
.agents directory
The .agents/ directory stores per-project configuration for swain skills:
execution-tracking.vars.json— swain-do first-run configspecwatch.log— swain-design stale reference logtrovewatch.log— swain-search pool refresh log
Check and create
if [[ ! -d ".agents" ]]; then
mkdir -p ".agents"
fiIf created, tell the user:
Created .agents/ directory for skill configuration storage.If it already exists, this step is silent.
Status cache bootstrap
If the memory directory exists but status-cache.json does not, and the status script is available, seed an initial cache so that consumers have data on first use.
STATUS_SCRIPT="$REPO_ROOT/.agents/bin/swain-status.sh"
if [[ -f "$STATUS_SCRIPT" && ! -f "$MEMORY_DIR/status-cache.json" ]]; then
bash "$STATUS_SCRIPT" --json > /dev/null 2>&1 || true
fiIf the cache was created, tell the user:
Seeded initial status cache. The MOTD and status dashboard now have data.
If the script is not available or the cache already exists, this step is silent. If the script fails, ignore — the cache will be created on the next swain-roadmap invocation.
Tickets Directory Validation
Runs every session after governance checks. Idempotent. Skip entirely if `.tickets/` does not exist.
Step 1 — Validate ticket YAML frontmatter
for f in .tickets/*.md; do
[ -f "$f" ] || continue
if ! head -1 "$f" | grep -q '^---$'; then
echo "invalid: $f (missing frontmatter open)"
elif ! sed -n '2,/^---$/p' "$f" | tail -1 | grep -q '^---$'; then
echo "invalid: $f (missing frontmatter close)"
fi
doneIf invalid files found → warn: "Found N ticket(s) with invalid YAML frontmatter. tk may not be able to read these." If all valid → silent.
Step 2 — Detect stale lock files
if [ -d .tickets/.locks ]; then
find .tickets/.locks -type f -mmin +60 2>/dev/null
fiIf stale locks found (> 1 hour old) → warn and list files. Do not auto-delete — ask the user first.
Extended tk health checks
Vendored tk availability
TK_BIN="$SKILLS_ROOT/swain-do/bin/tk"
if [ ! -x "$TK_BIN" ]; then
echo "warning: vendored tk not found or not executable at $TK_BIN"
fiIf missing → warn: "Reinstall swain skills to restore it."
Stale lock files (same as Step 2)
Check .tickets/.locks/ for files older than 1 hour. Ask before deleting.
Tool Availability
Check for required and optional external tools. Report results as a table. Classify each finding per ADR-020's three-tier remediation model:
- Self-heal: local, idempotent fixes — execute silently with advisory log line
- Bundle-offer: external installs (e.g.,
brew install) — collect into a fix plan presented at scan end, require operator consent before executing - Report-only: judgment calls — show as warnings, no fix offered
Required tools
These tools are needed by multiple skills. If missing, warn the user.
| Tool | Check | Used by | Install hint (macOS) |
|---|---|---|---|
git | command -v git | All skills | Xcode Command Line Tools |
jq | command -v jq | swain-init, swain-teardown, swain-do | brew install jq |
Optional tools
These tools enable specific features. If missing, note which features are degraded.
| Tool | Check | Used by | Degradation | Install hint (macOS) | Action |
|---|---|---|---|---|---|
tk | [ -x "$SKILLS_ROOT/swain-do/bin/tk" ] | swain-do | Task tracking unavailable; status skips task section | Vendored at swain-do/bin/tk -- reinstall swain if missing | report-only |
uv | command -v uv | swain-do (plan ingestion) | Plan ingestion unavailable | brew install uv | bundle-offer |
gh | command -v gh | swain-roadmap (GitHub issues), swain-release, swain-teardown | Status skips issues section; release can't create GitHub releases | brew install gh | bundle-offer |
tmux | which tmux | swain-init | Session tab-naming unavailable outside tmux | brew install tmux | bundle-offer |
fswatch | command -v fswatch | swain-design (specwatch live mode) | Live artifact watching unavailable; on-demand specwatch.sh scan still works | brew install fswatch | bundle-offer |
ssh | command -v ssh | swain-keys, git SSH alias remotes | Project-specific GitHub SSH aliases cannot be used from this runtime | brew install openssh | bundle-offer |
rtk | command -v rtk | git-compact (context-window compression) | git-compact passes through to raw git — no compression savings | brew install rtk | bundle-offer |
Reporting format
After checking all tools, output a summary:
Tool availability:
git .............. ok
jq ............... ok
tk ............... ok (vendored)
uv ............... ok
gh ............... ok
tmux ............. ok
tmux ............. WARN — tmux not found — session tab-naming unavailable. [offer to install]
fswatch .......... MISSING — live specwatch unavailable. Install: brew install fswatchOnly flag items that need attention. If all required tools are present, the check is silent except for missing optional tools that meaningfully degrade the experience.
Remediation (ADR-020)
After all checks complete, collect findings by action tier:
1. Self-heal findings execute silently during the scan (advisory log line only) 2. Bundle-offer findings are collected into a fix plan presented once at the end:
Doctor found 2 fixable issues:
1. tmux not installed — session tab-naming unavailable
Fix: brew install tmux
2. rtk not installed — git-compact passes through without compression
Fix: brew install rtk
Run all fixes? [y/N]3. Report-only findings are shown as warnings with no fix offered
On operator approval, execute each fix command sequentially and report per-item success/failure. On decline, log findings as advisories and continue.
With --auto-fix, self-heal fixes run but bundle-offer fixes are skipped (no operator to consent).
Stale Worktree Detection
Enumerate all linked worktrees and classify their health. Skip if the repo has no linked worktrees (i.e., git worktree list --porcelain returns only the main worktree entry) — this check produces no output in a clean repo.
Detection
git worktree list --porcelainParse each linked worktree (exclude the main worktree — the first entry in the output):
git worktree list --porcelain | awk '
/^worktree / { path=$2 }
/^branch / { branch=$2 }
/^$/ { if (path != "") print path, branch; path=""; branch="" }
' | tail -n +2For each linked worktree:
1. Orphaned — directory does not exist on disk ([ ! -d "$path" ]):
- WARN: "Orphaned worktree:
<path>(directory missing). Clean up with:git worktree prune"
2. Stale (merged) — directory exists and branch is fully merged into trunk:
git merge-base --is-ancestor "$branch" origin/trunk- WARN: "Stale worktree:
<path>(branch<branch>already merged into trunk). Safe to remove:
git worktree remove <path> && git branch -d <branch>"
3. Active (unmerged) — directory exists and branch has commits not in trunk:
- INFO: "Active worktree:
<path>(branch<branch>, N commits ahead of trunk). Do not remove — work in progress."
Do not remove any worktree automatically. All output is advisory.
Status values
- ok — no linked worktrees, or all are active
- warning — one or more stale or orphaned worktrees found (provide cleanup commands per item)
#!/usr/bin/env bash
# check-skill-changes.sh — Detect non-trivial skill file changes on trunk
#
# Scans the last N commits (default: 10) on the current branch for commits
# that touch skill files with non-trivial diffs.
#
# Triviality threshold (all must hold for a commit to be trivial):
# - Touches exactly 1 skill file
# - Total diff is ≤5 lines (insertions + deletions)
# - No structural changes (new sections, frontmatter field adds/removes, version bumps)
#
# Exit 0 = clean (no non-trivial skill changes found)
# Exit 1 = non-trivial skill changes detected (advisory warning emitted)
#
# Usage: bash check-skill-changes.sh [--commits N]
set -euo pipefail
COMMIT_COUNT=10
while [[ $# -gt 0 ]]; do
case "$1" in
--commits) COMMIT_COUNT="$2"; shift 2 ;;
*) shift ;;
esac
done
# Skill file path patterns
SKILL_PATHS="skills/ .claude/skills/ .agents/skills/"
found_issues=()
# Scan recent commits
while IFS= read -r commit_hash; do
[[ -z "$commit_hash" ]] && continue
# Get list of skill files changed in this commit
skill_files=()
while IFS= read -r file; do
[[ -z "$file" ]] && continue
for prefix in $SKILL_PATHS; do
if [[ "$file" == ${prefix}* ]]; then
skill_files+=("$file")
break
fi
done
done < <(git diff-tree --no-commit-id --name-only -r "$commit_hash" 2>/dev/null)
# Skip commits that don't touch skill files
[[ ${#skill_files[@]} -eq 0 ]] && continue
# Multi-file skill change is always non-trivial
if [[ ${#skill_files[@]} -gt 1 ]]; then
found_issues+=("$commit_hash")
continue
fi
# Single skill file — check diff size
file="${skill_files[0]}"
diff_stat=$(git diff-tree --no-commit-id --numstat -r "$commit_hash" -- "$file" 2>/dev/null)
insertions=$(echo "$diff_stat" | awk '{print $1}')
deletions=$(echo "$diff_stat" | awk '{print $2}')
# Handle binary files
if [[ "$insertions" == "-" || "$deletions" == "-" ]]; then
found_issues+=("$commit_hash")
continue
fi
total_lines=$((insertions + deletions))
# Over 5 lines = non-trivial
if [[ $total_lines -gt 5 ]]; then
found_issues+=("$commit_hash")
continue
fi
# Check for structural changes even in small diffs
diff_content=$(git diff-tree --no-commit-id -p -r "$commit_hash" -- "$file" 2>/dev/null)
# Version bump detection (version field change in frontmatter)
if echo "$diff_content" | grep -qE '^\+.*version:'; then
found_issues+=("$commit_hash")
continue
fi
# New section detection (added ## heading)
if echo "$diff_content" | grep -qE '^\+##\s'; then
found_issues+=("$commit_hash")
continue
fi
# Frontmatter field addition/removal
if echo "$diff_content" | grep -qE '^\+[a-z_-]+:' | head -1; then
# Check if we're inside frontmatter (between --- markers)
in_frontmatter=false
while IFS= read -r line; do
if [[ "$line" == "---" || "$line" == "+---" || "$line" == " ---" ]]; then
if $in_frontmatter; then
in_frontmatter=false
else
in_frontmatter=true
fi
continue
fi
if $in_frontmatter && echo "$line" | grep -qE '^\+[a-z_-]+:'; then
found_issues+=("$commit_hash")
break 2
fi
done <<< "$diff_content"
fi
done < <(git log --format='%H' -n "$COMMIT_COUNT" 2>/dev/null)
# Report
if [[ ${#found_issues[@]} -eq 0 ]]; then
exit 0
fi
for commit in "${found_issues[@]}"; do
short=$(git log --format='%h %s' -n 1 "$commit" 2>/dev/null)
echo "⚠ Trunk commit $short touches skill files with non-trivial changes."
echo " Skill changes above the triviality threshold should use worktree branches."
done
exit 1
#!/usr/bin/env bash
# crash-debris-lib.sh — standalone crash debris detection functions (SPEC-182)
#
# Each function takes a project root path as $1 and prints findings
# to stdout as tab-separated lines: TYPE\tSTATUS\tDETAIL
#
# STATUS values: found, clean
# When STATUS=found, DETAIL contains human-readable description
#
# These functions are sourceable by both the pre-runtime script
# (SPEC-180) and swain-doctor (SPEC-192).
# Check for stale .git/index.lock
# $1 = project root (must contain .git/ or be a worktree)
check_git_index_lock() {
local root="$1"
local git_dir="$root/.git"
# Handle worktree: .git may be a file pointing to the real git dir
if [[ -f "$git_dir" ]]; then
git_dir=$(sed 's/^gitdir: //' "$git_dir")
# Resolve relative paths
[[ "$git_dir" != /* ]] && git_dir="$root/$git_dir"
fi
local lock="$git_dir/index.lock"
if [[ ! -f "$lock" ]]; then
printf "git_index_lock\tclean\n"
return
fi
# Check if creating PID is still alive
local pid
pid=$(cat "$lock" 2>/dev/null | head -1 | grep -oE '^[0-9]+$' || echo "")
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
# PID alive — lock is legitimate
printf "git_index_lock\tclean\tlock held by live PID %s\n" "$pid"
return
fi
printf "git_index_lock\tfound\t%s (owner PID %s not running)\n" "$lock" "${pid:-unknown}"
}
# Check for interrupted git operations (merge, rebase, cherry-pick)
check_interrupted_git_ops() {
local root="$1"
local git_dir="$root/.git"
if [[ -f "$git_dir" ]]; then
git_dir=$(sed 's/^gitdir: //' "$git_dir")
[[ "$git_dir" != /* ]] && git_dir="$root/$git_dir"
fi
local found=()
[[ -f "$git_dir/MERGE_HEAD" ]] && found+=("interrupted merge (MERGE_HEAD)")
[[ -d "$git_dir/rebase-merge" ]] && found+=("interrupted rebase (rebase-merge/)")
[[ -d "$git_dir/rebase-apply" ]] && found+=("interrupted rebase-apply (rebase-apply/)")
[[ -f "$git_dir/CHERRY_PICK_HEAD" ]] && found+=("interrupted cherry-pick (CHERRY_PICK_HEAD)")
if [[ ${#found[@]} -eq 0 ]]; then
printf "interrupted_git_ops\tclean\n"
return
fi
for item in "${found[@]}"; do
printf "interrupted_git_ops\tfound\t%s\n" "$item"
done
}
# Check for stale tk claim locks (dead owner PID or age >1 hour)
check_stale_tk_locks() {
local root="$1"
local locks_dir="$root/.tickets/.locks"
if [[ ! -d "$locks_dir" ]]; then
printf "stale_tk_locks\tclean\n"
return
fi
local found=0
for lock_dir in "$locks_dir"/*/; do
[[ -d "$lock_dir" ]] || continue
local owner_file="$lock_dir/owner"
local task_id
task_id=$(basename "$lock_dir")
if [[ -f "$owner_file" ]]; then
local pid
pid=$(cat "$owner_file" 2>/dev/null | tr -d '[:space:]')
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
continue # alive — legitimate lock
fi
printf "stale_tk_locks\tfound\ttask %s locked by dead PID %s\n" "$task_id" "$pid"
else
# Lock dir exists but no owner file — treat as stale
printf "stale_tk_locks\tfound\ttask %s lock has no owner file\n" "$task_id"
fi
found=$((found + 1))
done
[[ $found -eq 0 ]] && printf "stale_tk_locks\tclean\n"
}
# Check for dangling worktrees (missing directory or merged branches)
check_dangling_worktrees() {
local root="$1"
local found=0
local in_first=1
local path="" branch=""
while IFS= read -r line; do
if [[ "$line" == worktree\ * ]]; then
path="${line#worktree }"
elif [[ "$line" == branch\ * ]]; then
branch="${line#branch }"
elif [[ -z "$line" ]]; then
if [[ $in_first -eq 1 ]]; then
in_first=0
path=""
branch=""
continue
fi
if [[ -n "$path" ]]; then
if [[ ! -d "$path" ]]; then
printf "dangling_worktrees\tfound\tmissing directory: %s (branch: %s)\n" "$path" "${branch:-detached}"
found=$((found + 1))
else
# Cross-reference with runtime sessions (best-effort)
local has_live_session=false
if [[ -d "$HOME/.claude/sessions" ]]; then
for sess in "$HOME/.claude/sessions"/*.json; do
[[ -f "$sess" ]] || continue
local sess_cwd sess_pid
sess_cwd=$(grep -o '"cwd":"[^"]*"' "$sess" 2>/dev/null | head -1 | sed 's/"cwd":"//;s/"$//')
if [[ "$sess_cwd" == "$path" ]]; then
sess_pid=$(grep -o '"pid":[0-9]*' "$sess" 2>/dev/null | head -1 | sed 's/"pid"://')
if [[ -n "$sess_pid" ]] && kill -0 "$sess_pid" 2>/dev/null; then
has_live_session=true
fi
fi
done
fi
if [[ "$has_live_session" == "true" ]]; then
continue
fi
local wt_status
wt_status=$(git -C "$path" status --porcelain 2>/dev/null | head -5)
if [[ -n "$wt_status" ]]; then
local change_count
change_count=$(git -C "$path" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
printf "dangling_worktrees\tfound\tuncommitted changes (%s files) in %s\n" "$change_count" "$path"
found=$((found + 1))
fi
fi
fi
path=""
branch=""
fi
done < <(git -C "$root" worktree list --porcelain 2>/dev/null; echo "")
[[ $found -eq 0 ]] && printf "dangling_worktrees\tclean\n"
}
# Check for orphaned MCP servers associated with this project
# Best-effort: matches process names containing "mcp" with cwd matching project root
check_orphaned_mcp() {
local root="$1"
local real_root
real_root=$(cd "$root" && pwd -P 2>/dev/null || echo "$root")
local found=0
while IFS= read -r line; do
[[ -z "$line" ]] && continue
local pid cmd
pid=$(echo "$line" | awk '{print $1}')
cmd=$(echo "$line" | awk '{$1=""; print $0}' | sed 's/^ //')
local proc_cwd=""
if [[ -d "/proc/$pid" ]]; then
proc_cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null || echo "")
else
proc_cwd=$(lsof -p "$pid" -Fn 2>/dev/null | grep '^n/' | head -1 | sed 's/^n//' || echo "")
fi
if [[ "$proc_cwd" == "$real_root"* ]]; then
printf "orphaned_mcp\tfound\tPID %s: %s\n" "$pid" "$cmd"
found=$((found + 1))
fi
done < <(ps aux 2>/dev/null | grep -i '[m]cp.*server\|[m]cp.*gateway' | grep -iv 'docker\|containerd' | awk '{print $2, $11, $12, $13}' || true)
[[ $found -eq 0 ]] && printf "orphaned_mcp\tclean\n"
}
# Run all crash debris checks and return combined results
# $1 = project root
# Returns: only "found" lines (tab-separated), or nothing if clean (AC5 silent fast path)
check_all_crash_debris() {
local root="$1"
local output=""
output+=$(check_git_index_lock "$root" 2>/dev/null)
output+=$'\n'
output+=$(check_interrupted_git_ops "$root" 2>/dev/null)
output+=$'\n'
output+=$(check_stale_tk_locks "$root" 2>/dev/null)
output+=$'\n'
output+=$(check_dangling_worktrees "$root" 2>/dev/null)
output+=$'\n'
output+=$(check_orphaned_mcp "$root" 2>/dev/null)
# AC5: silent fast path — only emit lines with findings, nothing if clean
echo "$output" | grep 'found' || true
}
#!/usr/bin/env bash
set -euo pipefail
# migrate-to-trunk-release.sh
# One-time migration from single-branch (main) to trunk+release model (SPEC-114, ADR-013).
# Idempotent — safe to run multiple times. Use --dry-run to preview.
DRY_RUN=false
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
info() { echo "==> $*"; }
warn() { echo "WARN: $*" >&2; }
die() { echo "ERROR: $*" >&2; exit 1; }
run() {
if $DRY_RUN; then
echo "[dry-run] $*"
else
info "Running: $*"
"$@"
fi
}
# ---------------------------------------------------------------------------
# Prerequisite checks
# ---------------------------------------------------------------------------
info "Checking prerequisites..."
command -v git >/dev/null 2>&1 || die "git is not installed"
command -v gh >/dev/null 2>&1 || die "gh CLI is not installed"
# Verify gh is authenticated
gh auth status >/dev/null 2>&1 || die "gh CLI is not authenticated — run 'gh auth login' first"
# Detect remote URL and extract owner/repo
REMOTE_URL="$(git remote get-url origin 2>/dev/null)" || die "No 'origin' remote found"
info "Remote URL: $REMOTE_URL"
# Extract owner/repo from SSH or HTTPS URLs
if [[ "$REMOTE_URL" =~ .*:(.+/.+)\.git$ ]]; then
OWNER_REPO="${BASH_REMATCH[1]}"
elif [[ "$REMOTE_URL" =~ .*:(.+/.+)$ ]]; then
OWNER_REPO="${BASH_REMATCH[1]}"
elif [[ "$REMOTE_URL" =~ github\.com/(.+/.+)\.git$ ]]; then
OWNER_REPO="${BASH_REMATCH[1]}"
elif [[ "$REMOTE_URL" =~ github\.com/(.+/.+)$ ]]; then
OWNER_REPO="${BASH_REMATCH[1]}"
else
die "Could not extract owner/repo from remote URL: $REMOTE_URL"
fi
info "GitHub repo: $OWNER_REPO"
# Must be on main branch (or trunk if re-running)
CURRENT_BRANCH="$(git symbolic-ref --short HEAD 2>/dev/null)" || die "Detached HEAD — check out a branch first"
if [[ "$CURRENT_BRANCH" != "main" && "$CURRENT_BRANCH" != "trunk" ]]; then
die "Must be on 'main' (or 'trunk' if re-running). Currently on '$CURRENT_BRANCH'."
fi
# Clean working tree
if ! git diff --quiet || ! git diff --cached --quiet; then
die "Working tree is dirty — commit or stash changes first"
fi
# Fetch latest remote state
info "Fetching latest remote state..."
git fetch origin
# ---------------------------------------------------------------------------
# Step 1: Rename main → trunk (locally and on remote)
# ---------------------------------------------------------------------------
info ""
info "--- Step 1: Rename main → trunk ---"
if git show-ref --verify --quiet refs/heads/trunk; then
info "Local branch 'trunk' already exists — skipping local rename."
else
if git show-ref --verify --quiet refs/heads/main; then
run git branch -m main trunk
else
info "No local 'main' branch found (already renamed?) — skipping."
fi
fi
# Push trunk to remote
if git ls-remote --heads origin trunk | grep -q trunk; then
info "Remote branch 'trunk' already exists — skipping push."
else
run git push origin trunk
fi
# ---------------------------------------------------------------------------
# Step 2: Create release branch from trunk HEAD
# ---------------------------------------------------------------------------
info ""
info "--- Step 2: Create release branch from trunk HEAD ---"
if git show-ref --verify --quiet refs/heads/release; then
info "Local branch 'release' already exists — skipping creation."
else
run git branch release trunk
fi
# Push release to remote
if git ls-remote --heads origin release | grep -q release; then
info "Remote branch 'release' already exists — skipping push."
else
run git push origin release
fi
# ---------------------------------------------------------------------------
# Step 3: Set release as the default branch on GitHub
# ---------------------------------------------------------------------------
info ""
info "--- Step 3: Set 'release' as the default branch on GitHub ---"
CURRENT_DEFAULT="$(gh api "repos/$OWNER_REPO" --jq '.default_branch' 2>/dev/null)" || true
if [[ "$CURRENT_DEFAULT" == "release" ]]; then
info "Default branch is already 'release' — skipping."
else
info "Current default branch: ${CURRENT_DEFAULT:-unknown}"
run gh api -X PATCH "repos/$OWNER_REPO" -f default_branch=release
fi
# ---------------------------------------------------------------------------
# Step 4: Push both branches (ensure up-to-date)
# ---------------------------------------------------------------------------
info ""
info "--- Step 4: Ensure both branches are pushed ---"
run git push origin trunk
run git push origin release
# ---------------------------------------------------------------------------
# Step 5: Delete old main branch on the remote
# ---------------------------------------------------------------------------
info ""
info "--- Step 5: Delete old 'main' branch on remote ---"
if git ls-remote --heads origin main | grep -q main; then
run git push origin --delete main
else
info "Remote branch 'main' does not exist — skipping deletion."
fi
# ---------------------------------------------------------------------------
# Step 6: Set upstream tracking
# ---------------------------------------------------------------------------
info ""
info "--- Step 6: Set upstream tracking for trunk ---"
if [[ "$(git symbolic-ref --short HEAD 2>/dev/null)" == "trunk" ]]; then
run git branch --set-upstream-to=origin/trunk trunk
fi
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
info ""
info "Migration complete!"
info " trunk → origin/trunk (development)"
info " release → origin/release (default branch, stable)"
if $DRY_RUN; then
info ""
info "(This was a dry run — no changes were made.)"
fi
#!/usr/bin/env bash
set -euo pipefail
# ssh-readiness.sh — validate and optionally repair per-project SSH alias wiring
#
# Usage:
# ssh-readiness.sh --check
# ssh-readiness.sh --repair
#
# Exit 0: SSH alias wiring is healthy or not applicable for this repo
# Exit 1: Remaining issues need operator action
MODE="${1:---check}"
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
issue_count=0
add_issue() {
echo "ISSUE: $*"
issue_count=$((issue_count + 1))
}
add_note() {
echo "NOTE: $*"
}
derive_project_from_alias_remote() {
local remote_url
remote_url="$(git -C "$REPO_ROOT" remote get-url origin 2>/dev/null || true)"
if [[ "$remote_url" =~ ^git@github\.com-([a-z0-9-]+): ]]; then
echo "${BASH_REMATCH[1]}"
fi
}
ensure_main_config_include() {
local ssh_dir="$1" config_dir="$2" main_config="$3"
mkdir -p "$ssh_dir" "$config_dir"
chmod 700 "$ssh_dir"
if [[ ! -f "$main_config" ]]; then
printf 'Include config.d/*\n' > "$main_config"
chmod 600 "$main_config"
add_note "Created $main_config with Include config.d/*"
return 0
fi
if ! grep -qF "Include config.d/*" "$main_config" 2>/dev/null; then
local tmp
tmp="$(mktemp)"
printf 'Include config.d/*\n\n' > "$tmp"
cat "$main_config" >> "$tmp"
mv "$tmp" "$main_config"
chmod 600 "$main_config"
add_note "Updated $main_config to include config.d/*"
fi
}
write_alias_config() {
local alias_file="$1" host_alias="$2" key_path="$3"
mkdir -p "$(dirname "$alias_file")"
cat > "$alias_file" <<EOF
# swain-doctor: per-project SSH config for ${host_alias}
Host ${host_alias}
HostName ssh.github.com
Port 443
User git
IdentityFile ${key_path}
IdentitiesOnly yes
EOF
chmod 600 "$alias_file"
add_note "Created $alias_file"
}
read_identity_file() {
local alias_file="$1"
awk '/^[[:space:]]*IdentityFile[[:space:]]+/ { print $2; exit }' "$alias_file" 2>/dev/null || true
}
alias_uses_github_443() {
local alias_file="$1"
grep -qF "HostName ssh.github.com" "$alias_file" 2>/dev/null \
&& grep -qF "Port 443" "$alias_file" 2>/dev/null
}
main() {
local project host_alias ssh_dir config_dir main_config alias_file default_key alias_key
project="$(derive_project_from_alias_remote)"
if [[ -z "$project" ]]; then
exit 0
fi
host_alias="github.com-${project}"
ssh_dir="$HOME/.ssh"
config_dir="$ssh_dir/config.d"
main_config="$ssh_dir/config"
alias_file="$config_dir/${project}.conf"
default_key="$ssh_dir/${project}_signing"
if ! command -v ssh >/dev/null 2>&1; then
add_issue "ssh client not found on PATH — install OpenSSH client before using ${host_alias}"
fi
if [[ "$MODE" == "--repair" ]]; then
ensure_main_config_include "$ssh_dir" "$config_dir" "$main_config"
else
if [[ ! -f "$main_config" ]]; then
add_issue "${host_alias} remote requires $main_config with 'Include config.d/*'"
elif ! grep -qF "Include config.d/*" "$main_config" 2>/dev/null; then
add_issue "$main_config is missing 'Include config.d/*' for ${host_alias}"
fi
fi
if [[ ! -f "$alias_file" ]]; then
if [[ "$MODE" == "--repair" && -f "$default_key" ]]; then
write_alias_config "$alias_file" "$host_alias" "$default_key"
else
add_issue "${host_alias} remote is configured but $alias_file is missing. Run swain-keys --provision."
fi
fi
if [[ -f "$alias_file" ]]; then
if ! grep -qE "^[[:space:]]*Host[[:space:]]+${host_alias}\$" "$alias_file" 2>/dev/null; then
add_issue "$alias_file does not define Host ${host_alias}"
fi
alias_key="$(read_identity_file "$alias_file")"
alias_key="${alias_key/#\~/$HOME}"
if [[ -z "$alias_key" ]]; then
add_issue "$alias_file is missing IdentityFile for ${host_alias}"
elif [[ ! -f "$alias_key" ]]; then
add_issue "${host_alias} points to missing key ${alias_key}. Run swain-keys --provision."
elif ! alias_uses_github_443 "$alias_file"; then
if [[ "$MODE" == "--repair" ]]; then
write_alias_config "$alias_file" "$host_alias" "$alias_key"
else
add_issue "$alias_file still targets legacy github.com:22. Re-run swain-keys --provision or doctor repair."
fi
fi
elif [[ ! -f "$default_key" ]]; then
add_issue "${host_alias} remote has no local key at $default_key. Run swain-keys --provision."
fi
if [[ $issue_count -gt 0 ]]; then
exit 1
fi
}
main
#!/usr/bin/env bash
# swain-doctor.sh — consolidated health check script (SPEC-192)
#
# Runs all swain-doctor checks in a single process with set +e,
# eliminating the parallel tool-call cascade failure where one
# erroring check cancels all sibling checks.
#
# Output: JSON object with { checks: [...], summary: {...} }
# Exit: always 0 — findings are reported in the JSON, not the exit code.
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$REPO_ROOT"
# Portable path resolution — works whether installed at skills/ or .agents/skills/
_src="${BASH_SOURCE[0]}"
while [[ -L "$_src" ]]; do
_dir="$(cd "$(dirname "$_src")" && pwd)"
_src="$(readlink "$_src")"
[[ "$_src" != /* ]] && _src="$_dir/$_src"
done
SCRIPT_DIR="$(cd "$(dirname "$_src")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
SKILLS_ROOT="$(dirname "$SKILL_DIR")"
LEGACY_SKILLS_LIB="$SKILL_DIR/references/legacy-skills-lib.sh"
if [[ -f "$LEGACY_SKILLS_LIB" ]]; then
# shellcheck disable=SC1090
source "$LEGACY_SKILLS_LIB"
fi
# Collect results
declare -a CHECKS=()
add_check() {
local name="$1"
local status="$2"
local message="${3:-}"
local detail="${4:-}"
local entry="{\"name\":\"$name\",\"status\":\"$status\""
if [[ -n "$message" ]]; then
# Escape quotes and newlines in message
message=$(echo "$message" | sed 's/"/\\"/g' | tr '\n' ' ')
entry="$entry,\"message\":\"$message\""
fi
if [[ -n "$detail" ]]; then
detail=$(echo "$detail" | sed 's/"/\\"/g' | tr '\n' ' ')
entry="$entry,\"detail\":\"$detail\""
fi
entry="$entry}"
CHECKS+=("$entry")
}
# ============================================================
# CLI flags
# ============================================================
FIX_FLAT=false
for arg in "$@"; do
case "$arg" in
--fix-flat-artifacts) FIX_FLAT=true ;;
esac
done
# ============================================================
# Check 1: Governance (SPEC-222: auto-repair stale block when markers present)
# ============================================================
check_governance() {
local gov_files
gov_files=$(grep -l "swain governance" CLAUDE.md AGENTS.md .cursor/rules/swain-governance.mdc 2>/dev/null || true)
if [[ -z "$gov_files" ]]; then
add_check "governance" "warning" "governance markers not found in any context file"
return
fi
# Freshness check
local canonical="$SKILL_DIR/references/AGENTS.content.md"
if [[ ! -f "$canonical" ]]; then
add_check "governance" "ok" "governance markers present (canonical source not found for freshness check)"
return
fi
local gov_file
gov_file=$(echo "$gov_files" | head -1)
extract_gov() { awk '/<!-- swain governance/{f=1;next}/<!-- end swain governance/{f=0}f' "$1"; }
local installed_hash canonical_hash
installed_hash=$(extract_gov "$gov_file" | shasum -a 256 | cut -d' ' -f1)
canonical_hash=$(extract_gov "$canonical" | shasum -a 256 | cut -d' ' -f1)
if [[ "$installed_hash" == "$canonical_hash" ]]; then
add_check "governance" "ok" "governance current"
return
fi
# Stale — attempt auto-repair if both markers are present
if grep -q '<!-- swain governance' "$gov_file" && grep -q '<!-- end swain governance' "$gov_file"; then
# Write canonical block content to temp file (avoids awk -v newline limitation)
local tmp_canonical
tmp_canonical=$(mktemp)
extract_gov "$canonical" > "$tmp_canonical"
awk -v tmpfile="$tmp_canonical" '
BEGIN{ while ((getline line < tmpfile) > 0) { buf = buf line "\n" } }
/<!-- swain governance/{print; p=1; printf "%s", buf; next}
/<!-- end swain governance/{p=0}
!p{print}
' "$gov_file" > "${gov_file}.tmp" && mv -f "${gov_file}.tmp" "$gov_file"
rm -f "$tmp_canonical"
add_check "governance" "advisory" "governance block updated to match canonical"
else
add_check "governance" "warning" "governance block is stale — markers missing, cannot auto-repair" "installed=$installed_hash canonical=$canonical_hash"
fi
}
# ============================================================
# Check 2: Legacy skill cleanup
# ============================================================
check_legacy_skills() {
local legacy_json="$SKILL_DIR/references/legacy-skills.json"
if [[ ! -f "$legacy_json" ]] || ! declare -F legacy_skill_entries >/dev/null 2>&1; then
add_check "legacy_skills" "warning" "legacy skill map unavailable — cannot check stale skill directories"
return
fi
local removed=()
local skipped=()
local kind old_name replacement base_dir skill_dir replacement_dir
while IFS=$'\t' read -r kind old_name replacement; do
[[ -n "$old_name" ]] || continue
for base_dir in "$REPO_ROOT/.agents/skills" "$REPO_ROOT/.claude/skills"; do
skill_dir="$base_dir/$old_name"
[[ -d "$skill_dir" ]] || continue
if [[ "$kind" == "renamed" ]]; then
replacement_dir="$base_dir/$replacement"
if [[ ! -d "$replacement_dir" ]]; then
skipped+=("$skill_dir (replacement missing: $replacement)")
continue
fi
fi
if ! legacy_skill_matches_fingerprint "$skill_dir" "$legacy_json"; then
skipped+=("$skill_dir (no swain fingerprint)")
continue
fi
rm -rf "$skill_dir"
if [[ "$kind" == "renamed" ]]; then
removed+=("$skill_dir -> $replacement")
else
removed+=("$skill_dir (absorbed by $replacement)")
fi
done
done < <(legacy_skill_entries "$legacy_json")
if [[ ${#removed[@]} -eq 0 && ${#skipped[@]} -eq 0 ]]; then
add_check "legacy_skills" "ok" "no legacy skill directories found"
return
fi
local detail=""
if [[ ${#removed[@]} -gt 0 ]]; then
detail="removed: ${removed[*]}"
fi
if [[ ${#skipped[@]} -gt 0 ]]; then
detail="${detail:+$detail; }manual review: ${skipped[*]}"
fi
if [[ ${#skipped[@]} -gt 0 ]]; then
add_check "legacy_skills" "warning" "legacy skill cleanup requires manual review" "$detail"
else
add_check "legacy_skills" "advisory" "removed ${#removed[@]} legacy skill director$( [[ ${#removed[@]} -eq 1 ]] && echo "y" || echo "ies" )" "$detail"
fi
}
# ============================================================
# Check 3: .agents directory
# ============================================================
check_agents_directory() {
if [[ -d .agents ]]; then
add_check "agents_directory" "ok" ".agents directory exists"
else
add_check "agents_directory" "warning" ".agents directory missing"
fi
}
# ============================================================
# Check 3: Tickets validation
# ============================================================
check_tickets() {
if [[ ! -d .tickets ]]; then
add_check "tickets" "ok" "no .tickets directory (skipped)"
return
fi
local invalid=0
for f in .tickets/*.md; do
[[ -f "$f" ]] || continue
if ! head -1 "$f" | grep -q '^---$'; then
invalid=$((invalid + 1))
fi
done
# Check stale locks
local stale_locks=""
if [[ -d .tickets/.locks ]]; then
stale_locks=$(find .tickets/.locks -type f -mmin +60 2>/dev/null | head -5 || true)
fi
if [[ $invalid -gt 0 && -n "$stale_locks" ]]; then
add_check "tickets" "warning" "$invalid invalid ticket(s), stale lock files found"
elif [[ $invalid -gt 0 ]]; then
add_check "tickets" "warning" "$invalid ticket(s) with invalid YAML frontmatter"
elif [[ -n "$stale_locks" ]]; then
add_check "tickets" "warning" "stale lock files in .tickets/.locks/"
else
add_check "tickets" "ok" ".tickets valid"
fi
}
# ============================================================
# Check 4: Stale .beads/ migration
# ============================================================
check_beads() {
if [[ -d .beads ]]; then
add_check "beads_migration" "warning" "stale .beads/ directory needs migration to .tickets/"
else
add_check "beads_migration" "ok" "no stale .beads/ (skipped)"
fi
}
# ============================================================
# Check 5: Tool availability
# ============================================================
check_tools() {
local missing_required=""
local missing_optional=""
# Required
for cmd in git jq; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing_required="${missing_required:+$missing_required, }$cmd"
fi
done
# Optional
for cmd in tk uv gh tmux fswatch; do
if [[ "$cmd" == "tk" ]]; then
if [[ ! -x "$SKILLS_ROOT/swain-do/bin/tk" ]]; then
missing_optional="${missing_optional:+$missing_optional, }tk"
fi
else
if ! command -v "$cmd" >/dev/null 2>&1; then
missing_optional="${missing_optional:+$missing_optional, }$cmd"
fi
fi
done
if [[ -n "$missing_required" ]]; then
add_check "tools" "warning" "required tools missing: $missing_required" "optional missing: ${missing_optional:-none}"
elif [[ -n "$missing_optional" ]]; then
add_check "tools" "ok" "all required tools present" "optional missing: $missing_optional"
else
add_check "tools" "ok" "all tools present"
fi
}
# ============================================================
# Check 6: Settings validation
# ============================================================
check_settings() {
local issues=""
if [[ ! -f swain.settings.json ]]; then
issues="swain.settings.json missing"
elif command -v jq >/dev/null 2>&1 && ! jq empty swain.settings.json 2>/dev/null; then
issues="swain.settings.json contains invalid JSON"
fi
local user_settings="${XDG_CONFIG_HOME:-$HOME/.config}/swain/settings.json"
if [[ -f "$user_settings" ]] && command -v jq >/dev/null 2>&1 && ! jq empty "$user_settings" 2>/dev/null; then
issues="${issues:+$issues; }user settings.json contains invalid JSON"
fi
if [[ -n "$issues" ]]; then
add_check "settings" "warning" "$issues"
else
add_check "settings" "ok" "settings valid"
fi
}
# ============================================================
# Check 7: Script permissions (SPEC-222: auto-repair)
# ============================================================
check_script_permissions() {
local bad_scripts_list
bad_scripts_list=$(find "$SKILLS_ROOT" -type f \( -path '*/scripts/*.sh' -o -path '*/scripts/*.py' \) ! -perm -u+x 2>/dev/null || true)
local bad_count=0
[[ -n "$bad_scripts_list" ]] && bad_count=$(echo "$bad_scripts_list" | grep -c .)
if [[ "$bad_count" -gt 0 ]]; then
local repaired=0
while IFS= read -r script; do
[[ -z "$script" ]] && continue
chmod +x "$script" 2>/dev/null && repaired=$((repaired + 1))
done <<< "$bad_scripts_list"
add_check "script_permissions" "advisory" "fixed execute permission on $repaired script(s)"
else
add_check "script_permissions" "ok" "all scripts executable"
fi
}
# ============================================================
# Check 8: Memory directory (SPEC-222: auto-repair)
# ============================================================
check_memory_directory() {
local project_slug
project_slug=$(echo "$REPO_ROOT" | tr '/' '-')
local memory_dir="$HOME/.claude/projects/${project_slug}/memory"
if [[ -d "$memory_dir" ]]; then
add_check "memory_directory" "ok" "memory directory exists"
else
mkdir -p "$memory_dir" 2>/dev/null
if [[ -d "$memory_dir" ]]; then
add_check "memory_directory" "advisory" "memory directory created at $memory_dir"
else
add_check "memory_directory" "warning" "memory directory missing and could not be created at $memory_dir"
fi
fi
}
# ============================================================
# Check 9: Superpowers detection
# ============================================================
check_superpowers() {
local found=0
local missing=0
local missing_names=""
for skill in brainstorming writing-plans test-driven-development verification-before-completion subagent-driven-development executing-plans; do
if [[ -f ".agents/skills/$skill/SKILL.md" ]] || [[ -f ".claude/skills/$skill/SKILL.md" ]]; then
found=$((found + 1))
else
missing=$((missing + 1))
missing_names="${missing_names:+$missing_names, }$skill"
fi
done
if [[ $missing -eq 0 ]]; then
add_check "superpowers" "ok" "$found/6 skills detected"
elif [[ $found -eq 0 ]]; then
add_check "superpowers" "warning" "superpowers not installed (0/6)" "$missing_names"
else
add_check "superpowers" "warning" "partial install ($found/6)" "missing: $missing_names"
fi
}
# ============================================================
# Check 10: Epics without parent-initiative
# ============================================================
check_epics_initiative() {
local count=0
while IFS= read -r -d '' f; do
if grep -q '^parent-vision:' "$f" 2>/dev/null && ! grep -q '^parent-initiative:' "$f" 2>/dev/null; then
count=$((count + 1))
fi
done < <(find docs/epic -name '*.md' -not -name 'README.md' -not -name 'list-*.md' -print0 2>/dev/null)
if [[ $count -gt 0 ]]; then
add_check "epics_initiative" "advisory" "$count epic(s) without parent-initiative"
else
add_check "epics_initiative" "ok" "all epics have parent-initiative or no epics exist"
fi
}
# ============================================================
# Check 11: Evidence pool / trove migration
# ============================================================
check_evidence_pools() {
if [[ -d docs/evidence-pools ]]; then
add_check "evidence_pools" "warning" "docs/evidence-pools/ exists — trove migration needed"
elif [[ -d docs/troves ]]; then
add_check "evidence_pools" "ok" "troves found"
else
add_check "evidence_pools" "ok" "no evidence pools or troves (skipped)"
fi
}
# ============================================================
# Check 12: Stale worktree detection
# ============================================================
check_worktrees() {
local worktree_count
worktree_count=$(git worktree list --porcelain 2>/dev/null | grep -c '^worktree ') || worktree_count=0
if [[ "$worktree_count" -le 1 ]]; then
add_check "worktrees" "ok" "no linked worktrees"
return
fi
local stale=0
local orphaned=0
# Parse linked worktrees (skip main — first entry)
local in_first=1
local path="" branch=""
while IFS= read -r line; do
if [[ "$line" == worktree\ * ]]; then
path="${line#worktree }"
elif [[ "$line" == branch\ * ]]; then
branch="${line#branch }"
elif [[ -z "$line" ]]; then
if [[ $in_first -eq 1 ]]; then
in_first=0
path=""
branch=""
continue
fi
if [[ -n "$path" ]]; then
if [[ ! -d "$path" ]]; then
orphaned=$((orphaned + 1))
elif [[ -n "$branch" ]] && git merge-base --is-ancestor "$branch" HEAD 2>/dev/null; then
stale=$((stale + 1))
fi
fi
path=""
branch=""
fi
done < <(git worktree list --porcelain 2>/dev/null; echo "")
# SPEC-246: Cross-reference lockfiles with worktrees
local lockfile_orphans=0
local unclaimed=0
local stale_locks=0
local lockfile_dir="$REPO_ROOT/.agents/worktrees"
local lockfile_script="$REPO_ROOT/.agents/bin/swain-lockfile.sh"
if [[ -d "$lockfile_dir" ]] && [[ -f "$lockfile_script" ]]; then
# Check lockfiles without corresponding worktrees
for lockfile in "$lockfile_dir"/*.lock; do
[[ -f "$lockfile" ]] || continue
local lock_wt_path=""
lock_wt_path=$(grep '^worktree_path=' "$lockfile" | head -1 | cut -d= -f2-)
if [[ -n "$lock_wt_path" ]] && [[ ! -d "$lock_wt_path" ]]; then
lockfile_orphans=$((lockfile_orphans + 1))
fi
# Check for stale lockfiles
local lock_branch
lock_branch="$(basename "$lockfile" .lock)"
if bash "$lockfile_script" is-stale "$lock_branch" >/dev/null 2>&1; then
stale_locks=$((stale_locks + 1))
fi
done
# Check worktrees without lockfiles (non-trunk)
local wt_in_first=1
local wt_path=""
while IFS= read -r line; do
if [[ "$line" == worktree\ * ]]; then
wt_path="${line#worktree }"
elif [[ -z "$line" ]]; then
if [[ $wt_in_first -eq 1 ]]; then
wt_in_first=0
wt_path=""
continue
fi
if [[ -n "$wt_path" ]]; then
# Check if any lockfile references this path
local has_lock=false
for lf in "$lockfile_dir"/*.lock; do
[[ -f "$lf" ]] || continue
if grep -q "worktree_path=$wt_path" "$lf" 2>/dev/null; then
has_lock=true
break
fi
done
if [[ "$has_lock" = false ]]; then
unclaimed=$((unclaimed + 1))
fi
fi
wt_path=""
fi
done < <(git worktree list --porcelain 2>/dev/null; echo "")
fi
# SPEC-290: Repair missing .swain/init.json symlinks in existing worktrees.
# Worktrees created before the symlink code existed (or via using-git-worktrees)
# lack .swain/init.json, causing swain-init-preflight to report "onboard" instead of "delegate".
#
# Source is always the MAIN repo root (first entry in git worktree list), not $REPO_ROOT,
# which may itself be a linked worktree. Repair covers all linked worktrees including
# the current one when running from inside a worktree.
local swain_init_repaired=0
local swain_init_missing=0
local main_root=""
main_root="$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')"
if [[ -n "$main_root" ]] && [[ -f "$main_root/.swain/init.json" ]]; then
local si_in_first=1
local si_path=""
while IFS= read -r line; do
if [[ "$line" == worktree\ * ]]; then
si_path="${line#worktree }"
elif [[ -z "$line" ]]; then
if [[ $si_in_first -eq 1 ]]; then
si_in_first=0
si_path=""
continue
fi
if [[ -n "$si_path" ]] && [[ -d "$si_path" ]]; then
if [[ ! -e "$si_path/.swain/init.json" ]]; then
mkdir -p "$si_path/.swain" 2>/dev/null || true
ln -s "$main_root/.swain/init.json" "$si_path/.swain/init.json" 2>/dev/null \
&& swain_init_repaired=$((swain_init_repaired + 1)) \
|| swain_init_missing=$((swain_init_missing + 1))
fi
fi
si_path=""
fi
done < <(git worktree list --porcelain 2>/dev/null; echo "")
# Also repair the current worktree if it's a linked worktree (REPO_ROOT != main_root).
if [[ "$REPO_ROOT" != "$main_root" ]] && [[ ! -e "$REPO_ROOT/.swain/init.json" ]]; then
mkdir -p "$REPO_ROOT/.swain" 2>/dev/null || true
ln -s "$main_root/.swain/init.json" "$REPO_ROOT/.swain/init.json" 2>/dev/null \
&& swain_init_repaired=$((swain_init_repaired + 1)) \
|| swain_init_missing=$((swain_init_missing + 1))
fi
fi
local total_issues=$((orphaned + stale + lockfile_orphans + unclaimed + stale_locks + swain_init_missing))
if [[ $total_issues -gt 0 ]]; then
local details=""
[[ $orphaned -gt 0 ]] && details="$orphaned orphaned"
[[ $stale -gt 0 ]] && details="${details:+$details, }$stale stale (merged)"
[[ $lockfile_orphans -gt 0 ]] && details="${details:+$details, }$lockfile_orphans lockfile(s) without worktree"
[[ $unclaimed -gt 0 ]] && details="${details:+$details, }$unclaimed unclaimed worktree(s)"
[[ $stale_locks -gt 0 ]] && details="${details:+$details, }$stale_locks stale lockfile(s)"
[[ $swain_init_missing -gt 0 ]] && details="${details:+$details, }$swain_init_missing worktree(s) missing .swain/init.json (symlink failed)"
add_check "worktrees" "warning" "$details"
else
local ok_msg="$((worktree_count - 1)) linked worktree(s), all active"
[[ $swain_init_repaired -gt 0 ]] && ok_msg="$ok_msg (repaired .swain/init.json symlink in $swain_init_repaired worktree(s))"
add_check "worktrees" "ok" "$ok_msg"
fi
}
# ============================================================
# Check 13a: Worktree context validation
# ============================================================
# Validates the CURRENT session's worktree (the one we're running
# in), not all linked worktrees (that's check_worktrees).
# Auto-fixes: ADR-034 location, lockfile creation, ADR-025 naming,
# folder == branch consistency.
# No symlink checks (ADR-042: track everything, not symlink).
# ============================================================
check_worktree_context() {
local git_common git_dir
git_common="$(git rev-parse --git-common-dir 2>/dev/null || true)"
git_dir="$(git rev-parse --git-dir 2>/dev/null || true)"
if [[ -n "$git_common" ]] && [[ "$git_common" != /* ]]; then
git_common="$(cd "$REPO_ROOT" && cd "$git_common" 2>/dev/null && pwd || echo "$git_common")"
fi
if [[ -n "$git_dir" ]] && [[ "$git_dir" != /* ]]; then
git_dir="$(cd "$REPO_ROOT" && cd "$git_dir" 2>/dev/null && pwd || echo "$git_dir")"
fi
if [[ -z "$git_common" ]] || [[ -z "$git_dir" ]] || [[ "$git_common" == "$git_dir" ]]; then
add_check "worktree_context" "ok" "not in a worktree"
return
fi
local fixed=0
local failed=0
local detail_parts=()
local main_root
main_root="$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')"
local branch
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'unknown')"
local current_wt_path="$REPO_ROOT"
# --- 1. Location sanity (ADR-034) — auto-move ---
local expected_parent="$main_root/.worktrees"
if [[ -n "$expected_parent" ]] && [[ "$current_wt_path" != "$expected_parent"/* ]]; then
local target_path="$expected_parent/$branch"
if [[ ! -e "$target_path" ]] && git worktree move "$current_wt_path" "$target_path" 2>/dev/null; then
fixed=$((fixed + 1))
detail_parts+=("moved to .worktrees/$branch (ADR-034)")
current_wt_path="$target_path"
else
failed=$((failed + 1))
detail_parts+=("outside .worktrees/ (ADR-034); fix: git worktree move $current_wt_path $target_path")
fi
fi
# --- 2. Lockfile creation — auto-create if missing ---
local lockfile_dir="$main_root/.agents/worktrees"
local lockfile_path="$lockfile_dir/$branch.lock"
local lockfile_script="$main_root/.agents/bin/swain-lockfile.sh"
if [[ ! -f "$lockfile_path" ]]; then
local lockfile_created=false
if [[ -x "$lockfile_script" ]]; then
local wt_purpose=""
if [[ -f "$main_root/.agents/session.json" ]]; then
wt_purpose=$(grep -o '"purpose":"[^"]*' "$main_root/.agents/session.json" 2>/dev/null | head -1 | sed 's/"purpose":"//')
fi
if bash "$lockfile_script" claim "$branch" "$REPO_ROOT" "$wt_purpose" >/dev/null 2>&1; then
fixed=$((fixed + 1))
lockfile_created=true
detail_parts+=("created lockfile for $branch")
fi
fi
if [[ "$lockfile_created" == "false" ]]; then
mkdir -p "$lockfile_dir"
local actual_lockfile="$lockfile_path"
if [[ -f "$lockfile_path" ]]; then
actual_lockfile="$lockfile_dir/$branch-$$.lock"
fi
local tmpfile
tmpfile="$(mktemp "$lockfile_dir/.claim-XXXXXX")"
cat > "$tmpfile" << LEOF
version=1
pid=$$
user=$(whoami)
exe=swain-doctor
pane_id=
claimed_at=$(date -Iseconds 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ)
worktree_path=$current_wt_path
purpose=
status=active
LEOF
mv "$tmpfile" "$actual_lockfile"
fixed=$((fixed + 1))
detail_parts+=("created lockfile for $branch")
fi
fi
# --- 3. Branch/folder naming (ADR-025) — auto-rename ---
_wt_name_matches_adr025() {
local name="$1"
echo "$name" | grep -qiE '^(spec|spike|adr|vision|journey|persona|runbook|design|train|epic|initiative)-[0-9]+' && return 0
echo "$name" | grep -qiE '^[a-z].*-[0-9]{8}-(epic|initiative)-[0-9]+' && return 0
echo "$name" | grep -qiE '^session-[0-9]{8}-[0-9]{6}' && return 0
return 1
}
if ! _wt_name_matches_adr025 "$branch"; then
local new_name=""
local name_script="$main_root/.agents/bin/swain-worktree-name.sh"
local wt_purpose=""
if [[ -f "$lockfile_dir/$branch.lock" ]]; then
wt_purpose=$(grep '^purpose=' "$lockfile_dir/$branch.lock" | head -1 | sed 's/^purpose=//' | sed 's/^"//;s/"$//')
fi
if [[ -x "$name_script" ]] && [[ -n "$wt_purpose" ]]; then
new_name=$(REPO_ROOT="$main_root" PURPOSE="$wt_purpose" bash "$name_script" "$wt_purpose" 2>/dev/null || true)
fi
if [[ -z "$new_name" ]]; then
new_name="session-$(date +%Y%m%d-%H%M%S)"
fi
if [[ -n "$new_name" ]] && [[ "$new_name" != "$branch" ]]; then
if git branch -m "$branch" "$new_name" 2>/dev/null; then
local new_lockfile="$lockfile_dir/$new_name.lock"
if [[ -f "$lockfile_dir/$branch.lock" ]] && [[ ! -f "$new_lockfile" ]]; then
mv "$lockfile_dir/$branch.lock" "$new_lockfile" 2>/dev/null
fi
local old_wt_path new_wt_path
old_wt_path="$main_root/.worktrees/$branch"
new_wt_path="$main_root/.worktrees/$new_name"
if [[ -d "$old_wt_path" ]]; then
git worktree move "$old_wt_path" "$new_wt_path" 2>/dev/null || true
fi
fixed=$((fixed + 1))
detail_parts+=("renamed $branch -> $new_name (ADR-025)")
branch="$new_name"
else
failed=$((failed + 1))
detail_parts+=("branch '$branch' violates ADR-025; auto-rename failed")
fi
fi
fi
# --- 4. Folder name == branch name ---
local folder_name
folder_name="$(basename "$current_wt_path")"
if [[ "$folder_name" != "$branch" ]]; then
local target_path="$main_root/.worktrees/$branch"
if [[ ! -e "$target_path" ]]; then
if git worktree move "$current_wt_path" "$target_path" 2>/dev/null; then
fixed=$((fixed + 1))
detail_parts+=("renamed folder $folder_name -> $branch")
current_wt_path="$target_path"
else
failed=$((failed + 1))
detail_parts+=("folder '$folder_name' != branch '$branch'; fix: git worktree move $current_wt_path $target_path")
fi
fi
fi
# --- Build result ---
if [[ ${#detail_parts[@]} -eq 0 ]]; then
add_check "worktree_context" "ok" "in worktree for $branch"
elif [[ $failed -eq 0 ]]; then
local detail_str
detail_str=$(printf '%s; ' "${detail_parts[@]}" | sed 's/; $//')
add_check "worktree_context" "advisory" "auto-fixed: $detail_str"
else
local detail_str
detail_str=$(printf '%s; ' "${detail_parts[@]}" | sed 's/; $//')
add_check "worktree_context" "warning" "$detail_str"
fi
}
# ============================================================
# Check 13: Lifecycle directory migration
# ============================================================
check_lifecycle_dirs() {
local old_phases="Draft Planned Review Approved Testing Implemented Adopted Deprecated Archived Sunset Validated"
local found=0
for dir in docs/*/; do
[[ -d "$dir" ]] || continue
for phase in $old_phases; do
local phase_dir="${dir}${phase}"
if [[ -d "$phase_dir" ]]; then
if find "$phase_dir" -maxdepth 1 -not -name '.*' -not -name "$(basename "$phase_dir")" -print -quit 2>/dev/null | grep -q .; then
found=$((found + 1))
fi
fi
done
done
if [[ $found -gt 0 ]]; then
add_check "lifecycle_dirs" "warning" "$found old lifecycle directory(ies) found — run migrate-lifecycle-dirs.py"
else
add_check "lifecycle_dirs" "ok" "no old lifecycle directories"
fi
}
# ============================================================
# Check 14: tk health
# ============================================================
check_tk_health() {
local tk_bin="$SKILLS_ROOT/swain-do/bin/tk"
if [[ ! -x "$tk_bin" ]]; then
add_check "tk_health" "warning" "vendored tk not found or not executable"
return
fi
if [[ ! -d .tickets ]]; then
add_check "tk_health" "ok" "tk available, no .tickets/ (skipped)"
return
fi
add_check "tk_health" "ok" "tk available and .tickets/ present"
}
# ============================================================
# Check 15: Operator bin/ symlinks (SPEC-214, ADR-019)
# Scans installed skill `usr/bin/` manifest directories for operator-facing
# scripts and auto-repairs bin/ symlinks.
# ============================================================
check_operator_bin_symlinks() {
local bin_dir="$REPO_ROOT/bin"
local repaired=0
local conflicts=()
local repairs=()
local manifest_count=0
# Scan all usr/bin/ manifest directories in the skill tree
for manifest_dir in "$SKILLS_ROOT"/*/usr/bin; do
[[ -d "$manifest_dir" ]] || continue
for entry in "$manifest_dir"/*; do
[[ -e "$entry" || -L "$entry" ]] || continue
local cmd_name
cmd_name="$(basename "$entry")"
manifest_count=$((manifest_count + 1))
# Resolve the actual script through the manifest symlink
local script_path
script_path="$(cd "$manifest_dir" && readlink -f "$cmd_name" 2>/dev/null || true)"
if [[ -z "$script_path" || ! -f "$script_path" ]]; then
# Manifest entry points to a missing script — skip
continue
fi
# Compute relative path from bin/ to the script
local rel_path
rel_path="$(python3 -c "import os,sys; print(os.path.relpath(sys.argv[1], sys.argv[2]))" "$script_path" "$bin_dir" 2>/dev/null || echo "")"
[[ -z "$rel_path" ]] && continue
if [[ -L "$bin_dir/$cmd_name" ]]; then
# Symlink exists — check if target is correct
local current_target
current_target="$(readlink "$bin_dir/$cmd_name")"
if [[ "$(cd "$bin_dir" && readlink -f "$cmd_name" 2>/dev/null)" == "$script_path" ]]; then
continue # resolves correctly
fi
# Stale — replace
ln -sf "$rel_path" "$bin_dir/$cmd_name"
repairs+=("$cmd_name (stale, repaired)")
repaired=$((repaired + 1))
elif [[ -e "$bin_dir/$cmd_name" ]]; then
# Real file — conflict, don't overwrite
conflicts+=("$cmd_name")
else
# Missing — auto-repair
mkdir -p "$bin_dir"
ln -sf "$rel_path" "$bin_dir/$cmd_name"
repairs+=("$cmd_name (created)")
repaired=$((repaired + 1))
fi
done
done
if [[ "$manifest_count" -eq 0 ]]; then
add_check "operator_bin_symlinks" "ok" "no operator scripts in usr/bin/ manifests"
return
fi
local issues=()
[[ ${#conflicts[@]} -gt 0 ]] && issues+=("${#conflicts[@]} conflict(s): ${conflicts[*]}")
[[ ${#repairs[@]} -gt 0 ]] && issues+=("${#repairs[@]} repaired: ${repairs[*]}")
if [[ ${#issues[@]} -eq 0 ]]; then
add_check "operator_bin_symlinks" "ok" "bin/ symlinks for $manifest_count operator script(s) OK"
elif [[ ${#conflicts[@]} -gt 0 ]]; then
local detail
detail=$(printf '%s; ' "${issues[@]}")
add_check "operator_bin_symlinks" "warning" "bin/ symlink issues" "${detail%;* }"
else
local detail
detail=$(printf '%s; ' "${issues[@]}")
add_check "operator_bin_symlinks" "ok" "bin/ symlinks repaired" "${detail%;* }"
fi
}
# ============================================================
# Check 16: Commit signing (SPEC-222: auto-repair if signing key detectable)
# ============================================================
check_commit_signing() {
if [[ "$(git config --local commit.gpgsign 2>/dev/null)" == "true" ]]; then
add_check "commit_signing" "ok" "commit signing configured"
return
fi
# Detect a usable signing key
local key_found=false
local conventional_key="$HOME/.ssh/swain_signing"
local allowed_signers
allowed_signers=$(git config --global gpg.ssh.allowedSignersFile 2>/dev/null || echo "")
[[ -f "$conventional_key" ]] && key_found=true
[[ -n "$allowed_signers" && -f "$allowed_signers" ]] && key_found=true
if [[ "$key_found" == "true" ]]; then
git config --local commit.gpgsign true
git config --local gpg.format ssh
add_check "commit_signing" "advisory" "commit signing enabled (gpgsign=true, gpg.format=ssh)"
else
add_check "commit_signing" "warning" "commit signing not configured (no signing key detected)"
fi
}
# ============================================================
# Check 17: SSH alias readiness
# ============================================================
check_ssh_readiness() {
local ssh_helper="$SCRIPT_DIR/ssh-readiness.sh"
if [[ ! -x "$ssh_helper" ]]; then
add_check "ssh_readiness" "ok" "ssh-readiness helper not found (skipped)"
return
fi
local ssh_output
ssh_output=$(bash "$ssh_helper" --check 2>/dev/null || true)
if [[ -n "$ssh_output" ]]; then
local issue_count
issue_count=$(echo "$ssh_output" | grep -c "ISSUE:") || issue_count=0
add_check "ssh_readiness" "warning" "$issue_count SSH readiness issue(s)" "$ssh_output"
else
add_check "ssh_readiness" "ok" "SSH alias readiness OK"
fi
}
# Check: README existence (SPEC-208)
# ============================================================
check_readme() {
if [[ -f "README.md" ]]; then
add_check "readme" "ok" "README.md exists"
else
add_check "readme" "warning" "README.md missing — swain alignment loop has no public intent anchor"
fi
}
# ============================================================
# Check 12: Artifact index staleness (SPEC-227)
# Regenerates supported list-*.md files and reports deterministic repairs.
# ============================================================
check_artifact_indexes() {
local rebuild_script="$REPO_ROOT/.agents/bin/rebuild-index.sh"
if [[ ! -x "$rebuild_script" ]]; then
rebuild_script="$SKILLS_ROOT/swain-design/scripts/rebuild-index.sh"
fi
if [[ ! -x "$rebuild_script" ]]; then
add_check "artifact_indexes" "warning" "rebuild-index.sh not found or not executable"
return
fi
local repaired=()
local failures=()
local type dir_name docs_dir index_file before_exists before_hash after_hash
for type in spec epic initiative spike adr persona runbook design vision journey train; do
dir_name="$type"
case "$type" in
spike) dir_name="research" ;;
esac
docs_dir="$REPO_ROOT/docs/$dir_name"
index_file="$docs_dir/list-${type}.md"
[[ -d "$docs_dir" ]] || continue
before_exists=false
before_hash=""
if [[ -f "$index_file" ]]; then
before_exists=true
before_hash=$(shasum -a 256 "$index_file" | awk '{print $1}')
fi
if ! bash "$rebuild_script" "$type" >/dev/null 2>&1; then
failures+=("$type")
continue
fi
if [[ ! -f "$index_file" ]]; then
failures+=("$type")
continue
fi
after_hash=$(shasum -a 256 "$index_file" | awk '{print $1}')
if [[ "$before_exists" == "false" ]]; then
repaired+=("${type} (created)")
elif [[ "$before_hash" != "$after_hash" ]]; then
repaired+=("${type} (updated)")
fi
done
if [[ ${#failures[@]} -gt 0 ]]; then
local detail
detail=$(printf '%s, ' "${failures[@]}")
if [[ ${#repaired[@]} -gt 0 ]]; then
add_check "artifact_indexes" "warning" "artifact indexes partially repaired" "repaired: ${repaired[*]}; failed: ${detail%, }"
else
add_check "artifact_indexes" "warning" "artifact index rebuild failed" "${detail%, }"
fi
return
fi
if [[ ${#repaired[@]} -gt 0 ]]; then
add_check "artifact_indexes" "advisory" "repaired ${#repaired[@]} artifact index file(s)" "${repaired[*]}"
else
add_check "artifact_indexes" "ok" "artifact indexes current"
fi
}
# ============================================================
# Check 18: Crash debris detection (SPEC-182, SPEC-222: auto-repair git lock only)
# ============================================================
check_crash_debris() {
local lib="$SCRIPT_DIR/crash-debris-lib.sh"
if [[ ! -f "$lib" ]]; then
add_check "crash_debris" "ok" "crash-debris-lib.sh not found (skipped)"
return
fi
source "$lib"
local output
output=$(check_all_crash_debris "$REPO_ROOT" 2>/dev/null || true)
local found_count
found_count=$(echo "$output" | grep -c 'found' 2>/dev/null) || found_count=0
if [[ "$found_count" -eq 0 ]]; then
add_check "crash_debris" "ok" "no crash debris detected"
return
fi
# Auto-repair: remove stale .git/index.lock if found (safe regardless of other debris)
local lock_lines other_lines lock_removed=false
lock_lines=$(echo "$output" | grep 'found' | grep '^git_index_lock' || true)
other_lines=$(echo "$output" | grep 'found' | grep -v '^git_index_lock' || true)
if [[ -n "$lock_lines" ]]; then
local git_dir="$REPO_ROOT/.git"
if [[ -f "$git_dir" ]]; then
git_dir=$(sed 's/^gitdir: //' "$git_dir")
[[ "$git_dir" != /* ]] && git_dir="$REPO_ROOT/$git_dir"
fi
local lock_file="$git_dir/index.lock"
if [[ -f "$lock_file" ]]; then
rm -f "$lock_file"
lock_removed=true
fi
fi
if [[ "$lock_removed" == "true" && -z "$other_lines" ]]; then
add_check "crash_debris" "advisory" "removed stale .git/index.lock"
return
fi
if [[ "$lock_removed" == "true" ]]; then
# Lock removed but other debris remains — warn about remaining items
local remaining_count
remaining_count=$(echo "$other_lines" | grep -c . 2>/dev/null) || remaining_count=0
local details
details=$(echo "$other_lines" | cut -f3 | tr '\n' '; ' | sed 's/; $//')
add_check "crash_debris" "warning" "removed .git/index.lock; $remaining_count other debris item(s) remain" "$details"
return
fi
local details
details=$(echo "$output" | grep 'found' | cut -f3 | tr '\n' '; ' | sed 's/; $//')
add_check "crash_debris" "warning" "$found_count crash debris item(s) detected" "$details"
}
# ============================================================
# Check 19: bin/swain symlink (SPEC-180, ADR-019)
# ============================================================
check_swain_symlink() {
local symlink="$REPO_ROOT/bin/swain"
if [[ ! -L "$symlink" ]]; then
if [[ -f "$SKILLS_ROOT/swain/scripts/swain" ]]; then
add_check "swain_symlink" "warning" "bin/swain symlink missing (script exists at $SKILLS_ROOT/swain/scripts/swain)"
else
add_check "swain_symlink" "ok" "bin/swain not applicable (no pre-runtime script)"
fi
return
fi
if [[ ! -e "$symlink" ]]; then
add_check "swain_symlink" "warning" "bin/swain symlink broken (target missing)"
return
fi
add_check "swain_symlink" "ok" "bin/swain symlink resolves"
}
# ============================================================
# Check 20: .agents/bin/ symlink completeness (SPEC-206)
# Aligns with preflight auto-repair (ADR-019, SPEC-186):
# - Scans all executable files in the installed skill tree (not just .sh)
# - Excludes test-* and operator-facing scripts (SPEC-214 manifest-driven)
# - Uses os.path.relpath for portable symlink targets
# - Auto-repairs missing/stale symlinks (detect + fix)
# ============================================================
check_agents_bin_symlinks() {
local bin_dir="$REPO_ROOT/.agents/bin"
if [[ ! -d "$bin_dir" ]]; then
mkdir -p "$bin_dir"
add_check "agents_bin_symlinks" "warning" ".agents/bin/ directory was missing (created)"
return
fi
local broken=()
local missing=()
local stale=()
local repaired=0
# Check for broken symlinks in .agents/bin/
while IFS= read -r link; do
[[ -z "$link" ]] && continue
if [[ ! -e "$link" ]]; then
broken+=("$(basename "$link")")
rm -f "$link"
fi
done < <(find "$bin_dir" -type l 2>/dev/null)
# Build operator-script exclusion set from usr/bin/ manifests (SPEC-214)
local operator_scripts=" "
for manifest_dir in "$SKILLS_ROOT"/*/usr/bin; do
[[ -d "$manifest_dir" ]] || continue
for entry in "$manifest_dir"/*; do
[[ -e "$entry" || -L "$entry" ]] || continue
operator_scripts+="$(basename "$entry") "
done
done
# Scan all executable scripts in the installed skill tree (ADR-019 convention)
for skill_scripts_dir in "$SKILLS_ROOT"/*/scripts; do
[[ -d "$skill_scripts_dir" ]] || continue
for script in "$skill_scripts_dir"/*; do
[[ -f "$script" && -x "$script" ]] || continue
local script_name
script_name="$(basename "$script")"
# Skip test scripts and operator-facing scripts
[[ "$script_name" == test-* || "$script_name" == test_* ]] && continue
# Skip operator-facing scripts — those belong in bin/, not .agents/bin/
echo "$operator_scripts" | grep -q " $script_name " && continue
# Compute portable relative path (works in worktrees and trunk)
local target="$bin_dir/$script_name"
local rel_path
rel_path="$(python3 -c "import os,sys; print(os.path.relpath(sys.argv[1], sys.argv[2]))" "$script" "$bin_dir" 2>/dev/null || echo "")"
[[ -z "$rel_path" ]] && continue
if [[ -L "$target" ]] && [[ "$(readlink "$target")" == "$rel_path" ]]; then
continue # ok
elif [[ -e "$target" ]] && [[ ! -L "$target" ]]; then
missing+=("$script_name (conflict: real file)")
elif [[ -L "$target" ]]; then
# stale — wrong target
stale+=("$script_name")
ln -sf "$rel_path" "$target"
repaired=$((repaired + 1))
else
# missing — auto-repair
missing+=("$script_name")
ln -sf "$rel_path" "$target"
repaired=$((repaired + 1))
fi
done
done
local issues=()
[[ ${#broken[@]} -gt 0 ]] && issues+=("${#broken[@]} broken (removed): ${broken[*]}")
[[ ${#stale[@]} -gt 0 ]] && issues+=("${#stale[@]} stale (repaired): ${stale[*]}")
[[ ${#missing[@]} -gt 0 ]] && issues+=("${#missing[@]} missing (repaired): ${missing[*]}")
if [[ ${#issues[@]} -eq 0 ]]; then
add_check "agents_bin_symlinks" "ok" ".agents/bin/ symlinks complete"
elif [[ $repaired -gt 0 ]]; then
local detail
detail=$(printf '%s; ' "${issues[@]}")
add_check "agents_bin_symlinks" "advisory" "repaired $repaired .agents/bin/ symlink(s)" "${detail%;* }"
else
local detail
detail=$(printf '%s; ' "${issues[@]}")
add_check "agents_bin_symlinks" "warning" ".agents/bin/ symlink issues" "${detail%;* }"
fi
}
# Check: Flat-file artifact detection (ADR-027, SPEC-225)
check_flat_artifacts() {
local fix_mode=false
[[ "${1:-}" == "--fix" ]] && fix_mode=true
# Artifact directories to scan (type -> docs subdir)
# Retros excluded — SPEC-252 handles retro renumbering + foldering separately
local -a dirs=()
for d in docs/spec docs/epic docs/adr docs/initiative docs/research \
docs/vision docs/design docs/persona docs/runbook docs/journey \
docs/train; do
[[ -d "$d" ]] && dirs+=("$d")
done
if [[ ${#dirs[@]} -eq 0 ]]; then
add_check "flat_artifacts" "ok" "no artifact directories found (skipped)"
return
fi
# Find .md files in phase directories that are NOT inside a (TYPE-NNN) folder.
# Flat files sit at: docs/<type>/<Phase>/filename.md
# Foldered files sit at: docs/<type>/<Phase>/(TYPE-NNN)-Title/filename.md
# Also catch flat retros: docs/swain-retro/filename.md (no phase subdir)
local -a flat_files=()
for d in "${dirs[@]}"; do
while IFS= read -r f; do
[[ -z "$f" ]] && continue
local parent_name
parent_name="$(basename "$(dirname "$f")")"
# Skip index files and READMEs
local fname
fname="$(basename "$f")"
[[ "$fname" == list-* ]] && continue
[[ "$fname" == README.md ]] && continue
# If parent dir name starts with ( it's inside a folder — skip
[[ "$parent_name" == \(* ]] && continue
flat_files+=("$f")
done < <(find "$d" -maxdepth 3 -name "*.md" -not -path "*/_unparented/*" -not -path "*/_Related/*" -not -path "*/_Depends-On/*" 2>/dev/null)
done
if [[ ${#flat_files[@]} -eq 0 ]]; then
add_check "flat_artifacts" "ok" "all artifacts are foldered"
return
fi
if $fix_mode; then
local migrated=0
local failed=0
for f in "${flat_files[@]}"; do
# Parse artifact ID from frontmatter
local artifact_id
artifact_id=$(sed -n 's/^artifact: *//p' "$f" | head -1 | tr -d '[:space:]')
if [[ -z "$artifact_id" ]]; then
failed=$((failed + 1))
continue
fi
# Derive folder name from filename convention
local fname parent_dir folder_name target_dir target_file
fname="$(basename "$f" .md)"
parent_dir="$(dirname "$f")"
# Build canonical folder name from frontmatter title
local raw_title
raw_title=$(sed -n 's/^title: *"\{0,1\}\(.*\)"\{0,1\}$/\1/p' "$f" | head -1 | sed 's/^ *//;s/ *$//')
if [[ -n "$raw_title" ]]; then
# Title-Case with hyphens: lowercase → capitalize first letter of each word → spaces to hyphens
local title_slug
title_slug=$(echo "$raw_title" | sed 's/[^a-zA-Z0-9 -]//g; s/ */ /g; s/ /-/g')
folder_name="(${artifact_id})-${title_slug}"
elif [[ "$fname" == \(* ]]; then
# Filename already has parens — use as-is
folder_name="$fname"
elif [[ "$fname" == "${artifact_id}"* ]]; then
# Filename starts with artifact ID — wrap in parens
local title_part="${fname#"${artifact_id}"}"
title_part="${title_part#-}"
folder_name="(${artifact_id})-${title_part}"
else
# Fallback: wrap artifact ID + filename
folder_name="(${artifact_id})-${fname}"
fi
target_dir="${parent_dir}/${folder_name}"
target_file="${target_dir}/${folder_name}.md"
if [[ -d "$target_dir" ]]; then
failed=$((failed + 1))
continue
fi
mkdir -p "$target_dir"
git mv "$f" "$target_file" 2>/dev/null
if [[ $? -eq 0 ]]; then
migrated=$((migrated + 1))
else
# Fallback: plain move if not tracked
mv "$f" "$target_file" 2>/dev/null && migrated=$((migrated + 1)) || failed=$((failed + 1))
fi
done
if [[ $failed -gt 0 ]]; then
add_check "flat_artifacts" "warning" "migrated $migrated, failed $failed flat-file artifact(s)"
else
add_check "flat_artifacts" "advisory" "migrated $migrated flat-file artifact(s) to folders"
fi
else
# Report only
local detail
detail=$(printf '%s;' "${flat_files[@]:0:10}")
[[ ${#flat_files[@]} -gt 10 ]] && detail="${detail}... and $((${#flat_files[@]} - 10)) more"
add_check "flat_artifacts" "warning" "${#flat_files[@]} flat-file artifact(s) — run swain-doctor --fix-flat-artifacts to migrate" "$detail"
fi
}
# ============================================================
# Check: Branch model (ADR-013)
# ============================================================
check_branch_model() {
local has_trunk has_release
has_trunk=$(git rev-parse --verify trunk >/dev/null 2>&1 && echo "yes" || echo "no")
has_release=$(git rev-parse --verify release >/dev/null 2>&1 && echo "yes" || echo "no")
if [[ "$has_trunk" == "yes" && "$has_release" == "yes" ]]; then
add_check "branch_model" "ok" "trunk+release branches present"
else
local missing=""
[[ "$has_trunk" == "no" ]] && missing="trunk"
[[ "$has_release" == "no" ]] && missing="${missing:+$missing, }release"
add_check "branch_model" "advisory" "missing branch(es): $missing — see ADR-013"
fi
}
# ============================================================
# Check: Platform dotfolder cleanup
# ============================================================
check_platform_dotfolders() {
if ! command -v jq >/dev/null 2>&1; then
add_check "platform_dotfolders" "skipped" "jq not available"
return
fi
local json_file="$SKILL_DIR/references/platform-dotfolders.json"
if [[ ! -f "$json_file" ]]; then
add_check "platform_dotfolders" "skipped" "platform-dotfolders.json not found"
return
fi
local stubs=()
while IFS= read -r entry; do
local dotfolder cmd det found=false
dotfolder=$(echo "$entry" | jq -r '.project_dotfolder')
cmd=$(echo "$entry" | jq -r '.command // empty')
det=$(echo "$entry" | jq -r '.detection // empty')
if [[ -n "$cmd" ]] && command -v "$cmd" &>/dev/null; then
found=true
fi
if [[ "$found" == "false" && -n "$det" ]]; then
local det_expanded
det_expanded=$(echo "$det" | sed "s|~|$HOME|g")
det_expanded=$(eval echo "$det_expanded" 2>/dev/null || echo "")
[[ -n "$det_expanded" && -d "$det_expanded" ]] && found=true
fi
# If platform not installed but dotfolder exists in project, it's a stub
if [[ "$found" == "false" && -d "$REPO_ROOT/$dotfolder" ]]; then
# Verify it's an installer stub (only contains skills/ or is empty)
local entries
entries=$(ls -A "$REPO_ROOT/$dotfolder" 2>/dev/null | wc -l | tr -d ' ')
if [[ "$entries" -le 1 ]] && { [[ -d "$REPO_ROOT/$dotfolder/skills" ]] || [[ "$entries" -eq 0 ]]; }; then
stubs+=("$dotfolder")
fi
fi
done < <(jq -c '.platforms[]' "$json_file")
if [[ ${#stubs[@]} -eq 0 ]]; then
add_check "platform_dotfolders" "ok" "no orphaned platform dotfolders"
else
add_check "platform_dotfolders" "warning" "${#stubs[@]} orphaned platform dotfolder(s): ${stubs[*]}"
fi
}
# ============================================================
# Check: Skill folder gitignore hygiene
# ============================================================
check_skill_gitignore() {
# Skip if this is the swain source repo
local remote_url
remote_url="$(git remote get-url origin 2>/dev/null || true)"
if [[ "$remote_url" == *"cristoslc/swain"* ]]; then
add_check "skill_gitignore" "ok" "swain source repo — skill folders are tracked"
return
fi
local missing=()
for base in .claude/skills .agents/skills; do
[[ -d "$base" ]] || continue
for dir in "$base"/swain "$base"/swain-*/; do
[[ -d "$dir" ]] || continue
if ! git check-ignore -q "$dir" 2>/dev/null; then
missing+=("$dir")
fi
done
done
if [[ ${#missing[@]} -eq 0 ]]; then
add_check "skill_gitignore" "ok" "vendored swain skill folders gitignored (or none exist)"
else
add_check "skill_gitignore" "warning" "${#missing[@]} vendored swain skill folder(s) not gitignored: ${missing[*]}"
fi
}
# ============================================================
# Migrate legacy .swain-init marker to .swain/init.json
# ============================================================
if [[ -f ".swain-init" ]] && [[ ! -f ".swain/init.json" ]]; then
mkdir -p ".swain"
mv ".swain-init" ".swain/init.json"
fi
# ============================================================
# Run all checks (set +e so failures don't cascade)
# ============================================================
set +e
check_governance
check_legacy_skills
check_agents_directory
check_tickets
check_beads
check_tools
check_settings
check_script_permissions
check_memory_directory
check_superpowers
check_epics_initiative
check_readme
check_artifact_indexes
check_evidence_pools
check_worktrees
check_worktree_context
check_lifecycle_dirs
check_tk_health
check_operator_bin_symlinks
check_commit_signing
check_ssh_readiness
check_crash_debris
check_agents_bin_symlinks
check_branch_model
check_platform_dotfolders
check_skill_gitignore
if $FIX_FLAT; then
check_flat_artifacts --fix
else
check_flat_artifacts
fi
set -e
# ============================================================
# Build JSON output
# ============================================================
total=${#CHECKS[@]}
ok_count=0
warning_count=0
advisory_count=0
for check in "${CHECKS[@]}"; do
status=$(echo "$check" | sed -n 's/.*"status":"\([^"]*\)".*/\1/p')
case "$status" in
ok) ok_count=$((ok_count + 1)) ;;
warning) warning_count=$((warning_count + 1)) ;;
advisory) advisory_count=$((advisory_count + 1)) ;;
esac
done
# Assemble JSON
checks_json=""
for i in "${!CHECKS[@]}"; do
if [[ $i -gt 0 ]]; then
checks_json="$checks_json,"
fi
checks_json="$checks_json${CHECKS[$i]}"
done
cat <<ENDJSON
{"checks":[$checks_json],"summary":{"total":$total,"ok":$ok_count,"warning":$warning_count,"advisory":$advisory_count}}
ENDJSON
exit 0
#!/usr/bin/env bash
set -euo pipefail
# Scan epics without parent-initiative, grouped by parent-vision
# Usage: swain-initiative-scan.sh
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || {
echo "Error: not inside a git repository" >&2
exit 1
}
EPIC_DIR="$REPO_ROOT/docs/epic"
if [[ ! -d "$EPIC_DIR" ]]; then
echo "No docs/epic directory found — nothing to scan."
exit 0
fi
echo "=== Epics without parent-initiative ==="
echo ""
echo "VISION | EPIC | TITLE"
echo "-------|------|------"
found=0
while IFS= read -r -d '' f; do
if grep -q '^parent-vision:' "$f" 2>/dev/null && ! grep -q '^parent-initiative:' "$f" 2>/dev/null; then
vision=$(grep '^parent-vision:' "$f" | head -1 | sed 's/parent-vision: *//' | tr -d ' ')
artifact=$(grep '^artifact:' "$f" | head -1 | sed 's/artifact: *//' | tr -d ' ')
title=$(grep '^title:' "$f" | head -1 | sed 's/title: *//' | tr -d '"')
echo "$vision | $artifact | $title"
found=$((found + 1))
fi
done < <(find "$EPIC_DIR" -name '*.md' -not -name 'README.md' -not -name 'list-*.md' -print0 2>/dev/null | sort -z)
echo ""
if [[ "$found" -eq 0 ]]; then
echo "All epics have parent-initiative. Migration complete."
else
echo "$found epic(s) need parent-initiative assignment."
echo ""
orphan_count=0
echo "Orphaned epics (no parent-vision or parent-initiative):"
while IFS= read -r -d '' f; do
if ! grep -q '^parent-vision:' "$f" 2>/dev/null && ! grep -q '^parent-initiative:' "$f" 2>/dev/null; then
artifact=$(grep '^artifact:' "$f" | head -1 | sed 's/artifact: *//' | tr -d ' ')
title=$(grep '^title:' "$f" | head -1 | sed 's/title: *//' | tr -d '"')
echo " $artifact | $title"
orphan_count=$((orphan_count + 1))
fi
done < <(find "$EPIC_DIR" -name '*.md' -not -name 'README.md' -not -name 'list-*.md' -print0 2>/dev/null | sort -z)
if [[ "$orphan_count" -eq 0 ]]; then
echo " (none)"
fi
fi
#!/usr/bin/env bash
# test-preflight-skill-changes.sh — Verify preflight integrates skill-change detection
#
# Tests that swain-preflight.sh calls check-skill-changes.sh and includes
# its findings in the issues list (triggering exit 1 → doctor invocation).
#
# Usage: bash skills/swain-doctor/tests/test-preflight-skill-changes.sh
set +e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PREFLIGHT="$(cd "$SCRIPT_DIR/.." && pwd)/scripts/swain-preflight.sh"
CHECK_SCRIPT="$(cd "$SCRIPT_DIR/.." && pwd)/scripts/check-skill-changes.sh"
PASS=0
FAIL=0
pass() { echo " PASS: $1"; ((PASS++)); }
fail() { echo " FAIL: $1 — $2"; ((FAIL++)); }
echo "=== Preflight Skill Change Integration Tests ==="
echo ""
# --- AC5: Preflight calls check-skill-changes and exits 1 on findings ---
echo "--- AC5: Preflight includes skill-change check ---"
# The preflight script contains a reference to check-skill-changes.sh
if grep -q "check-skill-changes" "$PREFLIGHT"; then
pass "AC5: preflight references check-skill-changes.sh"
else
fail "AC5" "preflight does not reference check-skill-changes.sh"
fi
# Verify the check script exists and is executable
if [[ -x "$CHECK_SCRIPT" ]]; then
pass "AC5: check-skill-changes.sh exists and is executable"
else
fail "AC5" "check-skill-changes.sh missing or not executable"
fi
echo ""
echo "=== Summary ==="
echo "PASS: $PASS"
echo "FAIL: $FAIL"
if [[ $FAIL -gt 0 ]]; then
exit 1
fi