name: doctor
description: Validate consumer install state — read-only audit of pipeline.config, gh auth, GitHub labels, plugin registration, residual subtree artifacts, and base branch. --fix labels seeds the canonical pipeline labels idempotently; --fix config reconciles new PIPELINE_* knobs into the host pipeline.config (append-only, never overwriting host values). Usage: /pipeline:doctor [--fix labels | --fix config]
disable-model-invocation: false
allowed-tools: Bash, Read
Boot
At session start, before running any of the steps below, source the project's pipeline.config so the PIPELINE_* variables are available for the rest of this skill:
source "$(pwd)/pipeline.config" 2>/dev/null || source ./pipeline.config
# Self-resolve CLAUDE_PLUGIN_ROOT in case the env var is unset in the Bash subshell.
# Anchor via the plugin cache glob (var-independent — no chicken-and-egg dependence on
# CLAUDE_PLUGIN_ROOT to FIND the resolver). _cpr_dir is the dir prefix; literal source line.
_cpr_dir="${CLAUDE_PLUGIN_ROOT:+${CLAUDE_PLUGIN_ROOT}/}"
_cpr_dir="${_cpr_dir:-$(ls -d ${HOME}/.claude/plugins/cache/claude-pipeline-local/pipeline/*/ 2>/dev/null | sort -V | tail -1)}"
_cpr_dir="${_cpr_dir:-$(ls -d ${HOME}/.claude/plugins/cache/claude-pipeline/pipeline/*/ 2>/dev/null | sort -V | tail -1)}"
source "${_cpr_dir}scripts/_resolve-plugin-root.sh" 2>/dev/null || true
Doctor
gh auth → plugin install → pipeline.config → labels → settings wiring → allow-list → CLAUDE.md residual → base branch
Run the doctor script with the user-supplied flags (forward $@ verbatim so both /pipeline:doctor and /pipeline:doctor --fix labels work):
bash "${CLAUDE_PLUGIN_ROOT}/scripts/doctor.sh" "$@"
Report the full stdout (CHECK lines + summary table) to the user. If the exit code was non-zero, finish with "One or more checks failed — see the summary above." If zero, finish with "All checks passed."
Checks
State table — each row names a check, the trigger that fires it, the worst-case severity, and the remediation. Complex checks have their own sub-section below with the full state machine.
| Check | Trigger / what it audits | Severity | Remediation |
|---|---|---|---|
gh_installed |
gh on PATH AND major ≥ 2 (needed for --json baseRefName) |
FAIL | Upgrade gh |
gh_auth |
gh auth status succeeds |
FAIL | gh auth login |
gh_repo_reachable |
gh repo view $PIPELINE_REPO succeeds |
FAIL | Check perms / PIPELINE_REPO |
pipeline_config |
pipeline.config exists at project root + PIPELINE_REPO set |
FAIL | /pipeline:init (or copy pipeline.config.example and edit) |
claude_plugin_root |
CLAUDE_PLUGIN_ROOT resolves to a real plugin install (4 env-states) |
varies | See sub-section below |
plugin_loaded |
claude plugin list includes claude-pipeline |
WARN if claude not on PATH |
/plugin install pipeline@claude-pipeline |
labels_exist |
All 10 pipeline labels present (honors PIPELINE_LABELS_* overrides) |
FAIL | /pipeline:init seeds labels, or --fix labels (idempotent upsert) |
no_residual_subtree |
No .claude-pipeline/ or .pipeline-managed markers from the retired subtree installer |
FAIL | scripts/migrate-from-subtree.sh |
claude_md_residual |
Legacy section headers / .claude-pipeline/ paths / dangling .claude/{scripts,hooks,skills}/ refs / unprefixed slash commands (delegates to migration-cleanup-claudemd.sh; on-disk path verification) |
WARN | migrate-from-subtree.sh --keep-referenced |
settings_residual |
Pipeline-owned hook entries in .claude/settings.json; capability-impact annotation from _advisory-text.sh |
WARN (jq required) | --fix residual (interactive) |
skill_files_residual |
Consumer .claude/{skills,hooks,scripts,agents}/ files colliding with plugin-shipped (relative-path compare); *.template renders reported separately as consumer-required |
FAIL when stale <owner>/<repo> ≠ $PIPELINE_REPO |
Delete duplicate or fix legacy install |
consumer_drift |
Per-file drift classification (6 buckets) | varies — see sub-section | Per-bucket |
preservation_refs |
Why each duplicate is still here — six reference-source buckets; KEEP/DELETE verdict | WARN when any KEEP; never FAIL | Rewire then delete on KEEP |
base_branch_local |
Local branch $PIPELINE_BASE_BRANCH exists |
WARN if no upstream | git fetch && git checkout -b <base> origin/<base> |
base_branch_enforcement |
enforce-base-branch.py exists AND ≥1 PreToolUse Bash matcher invokes it. Defense-in-depth #295 |
FAIL if absent or unregistered | Restore plugin manifest or re-add matcher |
stdin_read_timeout_guards |
Consumer .claude/hooks/ files reading stdin without a timeout guard (Python json.load(sys.stdin)/sys.stdin.read() with no read_event_stdin/signal.alarm/select.select nearby; bash $(cat) with no timeout). #917 |
WARN (consumer-owned; never FAIL) | --fix stdin-guards |
agent_resource_caps |
Per-agent systemd-run --user scopes available so spawned agents run under a MemoryMax/TasksMax cgroup ceiling (probed via command -v systemd-run + a live --user --scope -- true smoke; honors PIPELINE_AGENT_MEMORY_MAX/PIPELINE_AGENT_TASKS_MAX). #918 |
PASS names the caps; WARN (never FAIL) when unavailable — agents run UNBOUNDED | Add swap + set MemoryMax/pids.max on the user slice, or run under a host that supports user scopes |
The shared scripts/_advisory-text.sh helper is the single source of truth for capability-impact annotation copy surfaced by settings_residual — also sourced by migrate-from-subtree.sh so the wording matches.
claude_plugin_root check
Validates CLAUDE_PLUGIN_ROOT resolves to a real plugin install. Captures a pre-resolve env snapshot to distinguish four states:
| Env state | Path valid? | Status | Rationale |
|---|---|---|---|
| Set in env | Yes | pass |
Operator opted in to a specific plugin version; doctor stays out of the way. |
| Empty | Self-resolution from ~/.claude/plugins/cache/claude-pipeline/pipeline/<latest>/ succeeded |
pass |
env empty + self-resolved from the plugin cache IS the recommended path — it picks the highest-version directory automatically and survives upgrades. Surfacing warn here misled v0.7.1 consumers into hardcoding CLAUDE_PLUGIN_ROOT. |
| Set in env | No (path missing or not a directory) | warn |
Likely stale config — operator pinned a version that no longer exists after a plugin upgrade. Unset (recommended) or update. |
| Empty | No plugin cache present | fail |
Plugin isn't installed. Run /plugin install pipeline@claude-pipeline. |
consumer_drift check
Per-file drift classification on top of skill_files_residual's duplicate presence. Delegates to scripts/diff-consumer-files.sh (stateless, also reused by migrate-from-subtree.sh); assigns one of six buckets:
| Bucket | Definition | Detection | Action |
|---|---|---|---|
| A | Byte-identical to plugin counterpart | cmp -s returns equal |
delete-local |
| B | Drifted; plugin strictly more capable | Plugin counterpart sources pipeline.config / _resolve-plugin-root.sh / _pipeline_config; local does not |
delete-local |
| B.bug | Bucket B + hardcoded literal disagrees with runtime config | Extracted PIPELINE_REPO / PIPELINE_WORKTREE_PREFIX / PIPELINE_TMUX_SESSION literal ≠ value from pipeline.config |
fail-active-bug (escalates check to FAIL) |
| C | Plugin removed a feature the local copy still uses | Any function name or --flag token in local missing from plugin counterpart (grep -qF) |
leave-flag-as-fork |
| D | Plugin-author dogfood, not shipped | Basename only present under ${CLAUDE_PLUGIN_ROOT}/.claude/, never under scripts//hooks//agents/ |
no-op |
| E | Retired tooling | Basename on hardcoded deny-list (RETIRED_BASENAMES in diff-consumer-files.sh) |
delete-local |
| F | Genuine consumer-owned | No plugin counterpart anywhere | no-op |
B-vs-C tie-break. If both heuristics fire, C wins — preserves consumer functionality when uncertain.
Check verdict.
- B.bug row →
fail(active bug — stale local overrides correct plugin behavior). - A / B / C / E row whose basename is in
LOAD_BEARING_HOOKS→fail(#295). The array inscripts/doctor.sh(currentlyenforce-base-branch.py,enforce-path-c-delegation.py,block_deletions.py) names hooks the pipeline depends on for defense-in-depth; stale local copies silently defeat the guardrail. Detail line:<n> load-bearing hook(s) drifted (<csv>) — defense-in-depth at risk. - Other A / B / C / E row →
warn(drift exists but not breaking). - Only D / F rows (or no rows) →
pass.
Textual-diff-only; no behavioral comparison. --fix drift is out of scope.
preservation_refs check
Answers "why is each duplicate still here, and should the consumer delete it?" Per consumer .claude/{scripts,hooks}/ file with a plugin-shipped collision, delegates to scripts/scan-preservation-refs.sh (also used by migrate-from-subtree.sh --keep-referenced); emits one per-file block with references + verdict (KEEP/DELETE) + advisory text.
Reference-source buckets (six total; copy from scripts/_advisory-text.sh::advisory_for_ref_source):
| Bucket | Definition | Verdict mapping |
|---|---|---|
active-wiring |
Reference in .claude/settings.json; file is wired into a live hook chain. |
KEEP |
falls-away |
Reference in .claude/skills/<n>/SKILL.md AND <n> is plugin-shipped (migration removes the skill). |
DELETE (when sole holding ref) |
consumer-skill-ref |
Reference in .claude/skills/<n>/SKILL.md AND <n> is consumer-authored (migration does NOT remove the skill). |
KEEP |
self-only |
Reference is inside the file itself (usage string or docstring); no external consumer. | DELETE (when sole holding ref) |
fork |
Reference in .claude/settings.json AND the file's consumer_drift bucket is C. |
KEEP |
doc-ref |
Reference in any other .md / .txt — CLAUDE.md, README.md, dev/audits/*.md, etc. |
KEEP (resolve manually post-migration) |
Verdict rule. DELETE when every reference is in {self-only, falls-away} OR no references found. KEEP when at least one reference is in {active-wiring, fork, consumer-skill-ref, doc-ref}.
Status mapping. pass when zero files or every verdict is DELETE; warn when any KEEP. Never fail — the only fail-grade signal in this domain is consumer_drift::B.bug. No --fix preservation-refs mode; doctor stays read-only.
Cache + plugin-skill match. scan-preservation-refs.sh calls diff-consumer-files.sh once at start and caches the per-path bucket. The falls-away vs consumer-skill-ref distinction requires ${CLAUDE_PLUGIN_ROOT}/skills/<n>/ to exist (basename match). When CLAUDE_PLUGIN_ROOT is unresolved, every SKILL.md ref defaults to consumer-skill-ref — conservatively correct, but under-reports safe-to-delete cases.
Mutating actions
--fix labels
/pipeline:doctor --fix labels seeds the 10 canonical pipeline labels on $PIPELINE_REPO via gh label create --force, which is an idempotent upsert — safe to re-run. The four configurable label rows (excluded, later, human, brainstorm) honor PIPELINE_LABELS_* overrides from pipeline.config.
--fix config
/pipeline:doctor --fix config reconciles the host pipeline.config against pipeline.config.example (#1038). It is a KEY-LEVEL MERGE, append-only: every PIPELINE_* key present (uncommented) in pipeline.config.example but ABSENT from the host pipeline.config is appended at the example default value. It NEVER overwrites an existing host value (PIPELINE_REPO and per-operator paths are sacred), preserves host comments/ordering/non-PIPELINE_ lines (it only appends), and skips commented #PIPELINE_* example lines (those are documentation defaults single-sourced at the read site via ${VAR:-default}, not required live keys). Keys with no safe default (empty, owner/repo, /path/..., the PIPELINE_MOCK_WEB_EVAL_* family) are surfaced as "added — needs your value" so you can fill them in rather than silently running empty. It accepts optional vOLD vNEW positional args (--fix config vX vY) so the change report can name the version delta — the doctor-on-update detector passes the diffed plugin versions through this way. The report lists the version delta, a labels line (pointing at the companion --fix labels seed), the envvars added (key=default), and the envvars still needing a value.
Defaults-in-code / config = overrides-only (#1052). The reconcile follows a defaults-in-code model, mirroring Claude Code itself: Claude Code writes nothing to settings.json on install or upgrade — defaults live in the binary and apply whenever a key is absent. The same here: a PIPELINE_* knob with a read-site ${VAR:-default} (or colon-less ${VAR-default}) is COMMENTED in pipeline.config.example — it is a documentation default that the read site owns, NOT a required live key — so the reconcile SKIPS it and the host stays clean. Seeding such a knob at its current default would PIN that value: when a future plugin version flips the default, a host carrying the explicit seeded line silently keeps the OLD value, while a non-overriding host follows the new read-site default. Seeding therefore defeats central default evolution — the exact property the defaults-in-code model preserves. Only no-safe-default required keys (PIPELINE_REPO, per-operator paths, the PIPELINE_MOCK_WEB_EVAL_* family) stay uncommented = seeded. The golden guard tests/test-doctor-golden-seed-set.sh is the forcing function: it pins the seeded key set against the real example, so any newly-added uncommented-defaulted knob turns the test red and forces the author to comment it (or consciously extend the golden list).
--fix residual
Interactive remediation for the three residual checks (claude_md_residual, settings_residual, skill_files_residual); y/N prompt per finding. DOCTOR_FIX_NONINTERACTIVE=1 auto-skips (everything defaults to No) — useful for CI smoke runs.
settings_residualpatching is delegated tomigrate-from-subtree.sh --patch settings(in-place jq rewrite with.bakbackup; ISO-timestamp collision suffix). Honors--dry-run,--assume-yes,--assume-no.claude_md_residualonly surfaces the report frommigration-cleanup-claudemd.sh; doctor does NOT editCLAUDE.mddirectly — it's user-authored prose.skill_files_residuallists each duplicate as ay/Nprompt; consumer-required.template-rendered paths are excluded from the prompts.- Consumer-required paths rendered from plugin
scripts/*.template/hooks/*.templateare never proposed for deletion (load-bearing). The.template-branch becomes obsolete once #215 lands.
--fix stdin-guards
Remediates the stdin_read_timeout_guards check (#917): patches consumer .claude/hooks/ files whose stdin reads lack a timeout guard so a never-closing stdin can no longer wedge the session. y/N prompt per file. Strategy split by file type:
- Plugin-shipped Python duplicate (basename also exists under
${CLAUDE_PLUGIN_ROOT}/hooks/) → re-sync: copies the now-guarded plugin file over the drifted consumer copy. This is the existing drift-remediation idiom and avoids fragile in-place AST surgery. - Consumer-authored bash hook → in-place
$(cat)→$(timeout 5 cat || true)rewrite, with a.bakbackup (same.bakconvention as--fix residual). - Consumer-authored Python hook (no plugin counterpart) is surfaced for manual review only — doctor does not attempt an in-place AST edit.
Unlike --fix residual (which auto-skips under DOCTOR_FIX_NONINTERACTIVE=1), --fix stdin-guards honors DOCTOR_FIX_NONINTERACTIVE=1 as auto-yes (applies the patch) so CI/tests can exercise the remediation path without a TTY.