doctor

star 0

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. Usage: /pipeline:doctor [--fix labels]

rjskene By rjskene schedule Updated 6/4/2026

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_HOOKSfail (#295). The array in scripts/doctor.sh (currently enforce-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 / .txtCLAUDE.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_residual patching is delegated to migrate-from-subtree.sh --patch settings (in-place jq rewrite with .bak backup; ISO-timestamp collision suffix). Honors --dry-run, --assume-yes, --assume-no.
  • claude_md_residual only surfaces the report from migration-cleanup-claudemd.sh; doctor does NOT edit CLAUDE.md directly — it's user-authored prose.
  • skill_files_residual lists each duplicate as a y/N prompt; consumer-required .template-rendered paths are excluded from the prompts.
  • Consumer-required paths rendered from plugin scripts/*.template / hooks/*.template are 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 hookin-place $(cat)$(timeout 5 cat || true) rewrite, with a .bak backup (same .bak convention 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.

Install via CLI
npx skills add https://github.com/rjskene/pipeline --skill doctor
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator