Cross-plugin system diagnostics for the claude-forge ecosystem. Checks installed plugins, hook compilation, duplicate hook detection, continuity system health, environment (Node, npm, VCS CLI), and log sizes. Use when: plugins seem broken, hooks are not firing, skills or agents have gone missing, after installing or updating plugins, or for periodic health checks. Triggers on: doctor, diagnose plugins, plugin health, hooks not working, skills missing, plugin not loading, system check, plugin ...
$npx -y skills add ArieGoldkin/claude-forge --skill doctor --agent claude-code
Installs into .claude/skills of the current project.
Are you the author of Doctor?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/ariegoldkin-doctor)
---
name: doctor
description: "Cross-plugin system diagnostics for the claude-forge ecosystem. Checks installed plugins, hook compilation, duplicate hook detection, continuity system health, environment (Node, npm, VCS CLI), and log sizes. Use when: plugins seem broken, hooks are not firing, skills or agents have gone missing, after installing or updating plugins, or for periodic health checks. Triggers on: doctor, diagnose plugins, plugin health, hooks not working, skills missing, plugin not loading, system check, plugin status, what is installed, troubleshoot plugins"
effort: low
---
# /doctor
Cross-plugin system diagnostics for the claude-forge ecosystem. Checks what's installed, what's built, what's configured, and what needs attention.
## When to Use
- After installing or updating plugins
- When hooks seem to not be working
- First time using the plugin system
- Monthly system check
- Troubleshooting unexpected behavior
## What It Does
- Detects all installed claude-forge (marketplace cache + local dev)
- Compares each plugin's recorded `installPath` against the disk — the cache glob and
`claude plugin list` are both metadata-derived and report a plugin installed regardless
- **Observes whether ctk hooks actually fired this session** (#82) — the only check here that
watches hooks *run* rather than inspecting files and records, both of which reported healthy
through a session with zero hook invocations
- Verifies hook compilation status (dist/bin/run-hook.js exists)
- Counts hook registrations per plugin, checks for duplicates
- Verifies continuity system setup (ledger, context monitor, shared-context.json)
- Checks environment (Node.js, npm, glab/gh for VCS)
- Checks log directory health and sizes
- Generates prioritized recommendations
## Execution Steps
### Step 1: Detect Installed Plugins
Scan for all 6 possible plugins:
```
Plugins to check: ctk, etk, dtk, atk, ftk
Legacy names (still resolve via glob fallback for older installs):
continuity-toolkit, engineering-toolkit, devops-toolkit,
ai-toolkit, frontend-toolkit
For each short name, check:
1. ~/.claude/plugins/cache/*/[plugin-name]/ (marketplace install)
2. Current working directory plugins/[source-dir]/ (local dev / monorepo)
— note: source dirs still use the legacy long names (e.g. plugins/continuity-toolkit/)
Extract version from .claude-plugin/plugin.json
```
### Step 1a: Verify the Recorded Install Path Still Resolves
**The Step 1 glob and the recorded install path can disagree.** The glob matches *any* version
folder under the plugin's cache directory, including stale ones left by earlier installs, while
`~/.claude/plugins/installed_plugins.json` records one specific `installPath` per plugin. When that
recorded directory is missing, the record and the disk are inconsistent — surface it.
`claude plugin list` will not surface it: it renders from the same metadata, so it prints
`✔ enabled` regardless of what is on disk. Compare the record against the disk:
```bash
python3 -c "
import json,os
d=json.load(open(os.path.expanduser('~/.claude/plugins/installed_plugins.json')))
for name,entries in sorted(d.get('plugins',{}).items()):
for e in entries:
p=e.get('installPath','')
print(('OK ' if os.path.isdir(p) else 'DANGLING'), name, e.get('version'), p)
"
```
| Result | Meaning | Report |
|---|---|---|
| All entries `OK` | Record and disk agree | continue to Step 2 |
| Any `DANGLING` | Record and disk disagree — worth repairing, but **not** proof the plugin is unloaded | INCONSISTENT RECORD — confirm with the load check below |
**A `DANGLING` row is not a verdict.** Claude Code has been observed serving a plugin normally while
its recorded path pointed at a deleted directory — on 2026-07-28 ctk's record named a nonexistent
`ctk/2.10.2` while 43 hook invocations fired from an existing `ctk/2.10.0` and every ctk skill and
agent was present. Do not report BROKEN INSTALL from this row alone; it would contradict the
hook-build and hook-count rows, which in that state are correct.
Settle loaded-vs-not by content, which is the only check that distinguished the two cases:
- Are the plugin's skills and agents present in this session?
- Do its hooks actually fire? (For ctk, the decisive signal — it owns all shared hooks, so if ctk is
genuinely unloaded then `security-blocker`, the auto-approve permission hooks, and every lifecycle
hook are absent and the session runs with no guardrails.)
If the load check shows the plugin **is** absent, repair the install. Both of these have been seen
to change plugin state; neither has been isolated as *the* fix, so try them in order and re-check
between:
```bash
claude plugin marketplace update <marketplace-name> # e.g. claude-forge
claude plugin install <plugin>@<marketplace-name> # may report "already installed"
```
Hooks load only at session start, so restart after repairing — and note that skills can reappear
without a restart, so their return is **not** evidence that hooks came back.
> Observed 2026-07-29: a session started with zero claude-forge plugins loaded while
> `claude plugin list` reported all five `✔ enabled` and `/doctor`'s glob still matched stale
> version folders — two metadata-derived signals reporting green over a real outage. All five
> records were also dangling, and an in-use sweep had run 3 seconds after the prior `SessionEnd`;
> **neither was shown to be the cause.** ctk's record was already dangling during the prior session,
> when the plugin loaded and ran normally, so the dangling record cannot be the mechanism and the
> sweep did not create it. What broke that session is still unidentified.
### Step 1b: Verify ctk Hooks Are Actually Firing (#82)
**This is the only check in this skill that observes hooks running.** Everything above it —
the Step 1 glob, the Step 1a install-path comparison, `claude plugin list` — describes *files
and records*. On 2026-07-29 all of those reported healthy while ctk's hooks fired **zero**
times for a whole session, so the session ran with no `security-blocker`, no permission hooks
and no continuity lifecycle, and nothing announced it. Step 1a would not reliably have caught
it: the same record/disk inconsistency was present during the *previous*, fully working
session.
Hooks stamp a marker on SessionStart and on every user prompt. **This check is the
only detection surface in 2.14.0** — a passive statusline warning was built and then
cut before release, because every serious defect two review rounds found lived in it
(chiefly: it asked whether a stamping-capable ctk was *installed* when the question
is whether the *loaded* hooks stamp, so upgrading ctk mid-session would have alarmed
a healthy one).
Absence is only meaningful under three conditions, all evaluated below. Do not
shorten this to "marker missing ⇒ broken":
```bash
# The Bash tool exports CLAUDE_CODE_SESSION_ID (note the _CODE_); hooks key the
# marker by the session id in their payload, which is the same UUID.
node -e "
const os=require('os'),fs=require('fs'),p=require('path');
const sid=process.env.CLAUDE_CODE_SESSION_ID||process.env.CLAUDE_SESSION_ID||'default';
// 'default' and 'unknown' are the two fallback constants the writer and reader
// layers substitute independently. Under either, marker absence is meaningless.
const trusted=!['default','unknown',''].includes(sid);
let installed=null;
try{
const dir=process.env.CLAUDE_CONFIG_DIR||p.join(os.homedir(),'.claude');
const recs=JSON.parse(fs.readFileSync(p.join(dir,'plugins','installed_plugins.json'),'utf8'));
// CC keys these records '<name>@<marketplace>' (e.g. 'ctk@claude-forge'), NOT 'ctk'.
const t=recs.plugins||{};
installed=Object.keys(t).filter(k=>k==='ctk'||k.startsWith('ctk@')).flatMap(k=>(t[k]||[]).map(e=>e.version)).filter(Boolean);
}catch{}
const cmp=(a,b)=>{const A=(a.split('-')[0]||'').split('.'),B=(b.split('-')[0]||'').split('.');
for(let i=0;i<Math.max(A.length,B.length);i++){const x=parseInt(A[i]||'0',10)||0,y=parseInt(B[i]||'0',10)||0;if(x!==y)return x-y;}return 0;};
const stamping=(installed||[]).some(v=>cmp(v,'2.14.0')>=0);
let rec=null; try{rec=JSON.parse(fs.readFileSync(p.join(os.tmpdir(),'claude-ctk-hook-alive-'+sid+'.txt'),'utf8'))}catch{}
console.log('session :', sid, trusted?'(trusted)':'(UNTRUSTED fallback id)');
console.log('installed ctk :', installed?installed.join(', '):'unreadable');
console.log('stamp expected:', stamping);
console.log('marker :', rec?('PRESENT — stamped by '+rec.hook+' at '+rec.at):'ABSENT');
console.log('VERDICT :', rec?'OK':(!trusted?'INCONCLUSIVE (untrusted session id)':(!stamping?'INCONCLUSIVE (installed ctk predates stamping)':'FAIL')));
"
```
| VERDICT | Meaning |
|---|---|
| `OK` | A ctk hook demonstrably ran in this session. Report the stamping hook and time. |
| `FAIL` | **This is the #82 state.** A stamping-capable ctk is installed and the session id is trustworthy, yet nothing stamped. Treat as a total or partial hook unload and capture evidence below. |
| `INCONCLUSIVE (installed ctk predates stamping)` | Not a fault. The installed ctk is older than 2.14.0 and its hooks never stamp. Report the version. |
| `INCONCLUSIVE (untrusted session id)` | Not a fault. The id fell back to a constant, so writer and reader may key different files. Report it as undetermined. |
> **Why not simply "marker absent ⇒ broken":** an earlier revision of this check
> keyed off a flag that stamping dropped in the temp directory, on the theory that
> its presence proved the install could stamp. The flag was machine-global and
> never expired, so **this repo's own test suite armed it** — and `/doctor` then
> reported FAIL on a completely healthy install. Ask CC's plugin records for the
> installed version instead of inferring capability from a side effect.
On **FAIL**, capture evidence *before* repairing anything. The 2026-07-29 occurrence was
repaired first, which destroyed the evidence and is why the trigger is still unidentified.
Collect, in this order: `ls -la ~/.claude/plugins/`; copies of `installed_plugins.json`,
`known_marketplaces.json` and `plugin-catalog-cache.json`; `git -C
~/.claude/plugins/marketplaces/<mkt> log --oneline -1` plus `status`; the `/plugin` **Errors**
tab; and `claude --output-format stream-json -p test | jq '.init.plugin_errors'`, which is the
runtime load-error signal `claude plugin list` lacks. Then repair **one command at a time**,
re-checking load state between each, so the fix is isolated — that was not done last time, and
is why no command can be credited with the repair.
**Do not report this check as OK on the strength of a passing Step 1 or 1a.** Their agreement
is exactly what was true while the hooks were dead.
### Step 1c: Look for PAST Hook Outages (#82)
Step 1b answers *"is a hook firing in **this** session?"* — it cannot see an outage that has
already ended. That is why #82 was believed to be a single event: nobody could look backwards.
This step looks backwards. It joins two per-hour series — hooked tool calls from CC's
transcripts, and lines in ctk's own permission/hook logs — and reports hours where tools ran and
hooks did not:
```bash
node "${CLAUDE_PLUGIN_ROOT}/hooks/dist/bin/detect-hook-outages.js"
```
| Exit | Meaning |
|---|---|
| `0` | **All-clear** — hours were actually examined and none was silent. |
| `1` | **Outage hours found.** Each is an hour where the session ran with no `security-blocker`, no permission hooks and no continuity lifecycle. |
| `2` | **Inconclusive** — no log directory, no coverage, no hour with *both* coverage and tool calls, or a bad argument. **Not an all-clear.** |
`0` and `2` are deliberately distinct: an earlier revision collapsed them, so a run that examined
nothing at all printed `OK`. Treat only `0` as evidence of health.
Add `--json` for machine-readable output, `--min-tools N` to change the pre-filter.
**Silence is probabilistic, not binary — do not lower the bar.** `bash-combined` writes a
permission line on auto-approve or deny but **not** when a command defers to a user prompt, and
`hooks.log` is WARN-level, so only ~0.5 log lines are written per hooked tool call. A quiet hour
of 5 tool calls happens by chance about 5% of the time. The tool therefore applies a
family-wise significance test against the **measured** rate rather than a fixed threshold, and
prints both (`logging rate`, and how many quiet hours it rejected as chance). On the machine
this was built against, a naive fixed threshold reported **18** outages where **7** were real.
**A finding here is historical, not live.** It says hooks were absent then. Run Step 1b to
learn whether they are absent *now*, and only follow the capture-before-repair checklist there
if 1b also fails.
### Step 1d: Compare the Plugin Tree Against Session Start (#82)
Steps 1b and 1c answer *whether* hooks stopped. This answers **what changed on disk when they
did** — the question two investigations could not answer, because every candidate artifact
(`.last_inuse_sweep`, a directory `mtime`, `installed_plugins.json`) records only current state,
and `mtime` is last-write-wins.
ctk now writes a compact plugin-tree snapshot at every session start (`session-loader`, ~8 ms,
~18 KB, newest 20 kept). Because measured outages begin **mid-session**, that snapshot is a
healthy "before":
```bash
node "${CLAUDE_PLUGIN_ROOT}/hooks/dist/bin/detect-hook-outages.js" --state-diff
```
| Exit | Meaning |
|---|---|
| `0` | Tree identical to session start. |
| `1` | **Changed.** `REMOVED since session start` is the strongest available evidence for the sweep hypothesis. |
| `2` | Inconclusive — no snapshots yet, or the snapshot is unreadable. **Not an all-clear.** |
**Run this at the moment an outage is noticed, before repairing anything.** The repair is what
destroyed the evidence in 2026-07-29, and a snapshot is only useful against live state.
Two honest limits: a session that started *already* broken has no snapshot, because nothing of
ctk's ran to write one; and a snapshot marked `⚠ TRUNCATED` cannot be compared safely, since its
missing entries look identical to removals.
### Step 2: Verify Hook Builds
For each installed plugin:
```
Check: [plugin-root]/hooks/dist/bin/run-hook.js exists?
YES → hooks built, report OK
NO → hooks NOT built, recommend: cd hooks && npm run build
```
### Step 3: Count Hook Registrations
For each installed plugin, read hooks.json and count event registrations.
Verify ctk is sole owner of shared hooks (other plugins should only have domain-specific hooks).
Flag if duplicate shared hooks detected across multiple plugins.
### Step 4: Check Continuity System
```
Ledger: .claude/continuity/ledgers/CONTINUITY_*.md exists?
Context monitor: see Step 4a — file existence alone is NOT the check
Shared context: .claude/context/shared-context.json exists?
Last session: was_cleanly_ended field value
```
### Step 4a: Verify the Context-Warning Pipeline Is Actually Wired
**The launcher existing does not mean context warnings work.** ctk's statusline script is the
only writer of the context-percentage temp file that the `context-monitor` hook reads to inject
the 70/80/90% warnings. If `statusLine` points at any *other* program — claude-hud, a custom
script, a different plugin — the launcher file still sits on disk untouched while the warnings
silently stop firing. Checking for the file alone reports a false healthy.
Read the configured command and compare it to ctk's launcher:
```bash
# The value that actually decides whether warnings fire
python3 -c "import json,os;d=json.load(open(os.path.expanduser('~/.claude/settings.json')));print(json.dumps(d.get('statusLine','(unset)')))"
```
Classify the result:
| `statusLine.command` | Context warnings | Report |
|---|---|---|
| Contains `continuity-statusline.sh` | Necessary, not sufficient — go to the end-to-end check | see below |
| Unset / no `statusLine` key | **Dead** | NOT CONFIGURED — run `/ctk:setup-context-monitor` |
| Runs ctk with `CONTINUITY_STATUSLINE_SILENT=1` alongside another program | **Healthy** — this is the composed launcher | OK (composed) |
| Points at any other program | **Dead** | CONFLICT — name the program; see the resolution below |
A composed launcher is *not* a conflict: if the command pipes the payload through ctk with
`CONTINUITY_STATUSLINE_SILENT=1`, the side effect still runs and the warnings still fire even
though another program owns the display. Read the launcher's contents before classifying.
**The config string alone does not prove the warnings fire.** The statusline writes the
percentage file and the hook reads it, keyed by session id on both sides; if those keys ever
disagree the file is written under one name and sought under another, and the warnings stay
silent with a perfectly correct `statusLine`. That exact defect shipped through ctk 2.7.4 — the
writer keyed off `CLAUDE_SESSION_ID`, which Claude Code does not export into the statusline
child process, so every file was written as `-default.txt` while the hook looked for the real
session UUID. Verify the artifact, not the intent:
```bash
# Does a percentage file exist for THIS session? (needs the current session id)
node -e "const os=require('os'),fs=require('fs');console.log(fs.readdirSync(os.tmpdir()).filter(f=>f.startsWith('claude-context-pct-')))"
```
| Files found | Meaning |
|---|---|
| One matching the current session id | Pipeline verified end-to-end — report OK |
| Only `claude-context-pct-default.txt` | *May* indicate the pre-2.8.0 keying bug — but `default` is also the legitimate fallback when the payload carries no `session_id` and `CLAUDE_SESSION_ID` is unset. Confirm the installed ctk version before recommending an update |
| None | The statusline has not run yet this session; re-check after a turn or two before reporting a fault |
On CONFLICT, state the consequence plainly rather than just flagging a mismatch: *"`statusLine`
runs <program>, so ctk's 70/80/90% context warnings are not firing."* Then offer three
resolutions, composition first:
1. **Keep both** — rebuild the launcher to run ctk with `CONTINUITY_STATUSLINE_SILENT=1` and
`<program>` for the display (`/ctk:setup-context-monitor` Step 1a). Usually the right answer.
2. Switch back to ctk alone with `/ctk:setup-context-monitor`.
3. Keep `<program>` alone and accept that the warnings are off.
Do not silently "fix" the user's `statusLine`; it is their configuration and they may have chosen
it deliberately.
### Step 5: Check Environment
```
node --version (required for hooks)
npm --version (required for hook builds)
glab --version (optional, for /review-mr with GitLab)
gh --version (optional, for /review-mr with GitHub)
```
### Step 6: Check Log Health
⚠ **Check `$CLAUDE_PLUGIN_DATA/logs/`, not a fallback path.** `logging.ts` prefers
`CLAUDE_PLUGIN_DATA` for live hooks. When it is unset the path falls back to
`$CLAUDE_CONFIG_DIR/logs/<short-name>/` (the tier added in #105, where the test suite now writes),
and only then to `~/.claude/logs/<short-name>/`. Neither fallback holds live-hook output: their
newest entries (`session=unknown`, fixture commands like `echo hi`) read exactly like recent live
activity, so sizing up a fallback directory reports on a file no live hook has touched. **The
three-tier rule is stated once, in ctk's CLAUDE.md → "Log locations".**
```bash
# Resolve the real directory first — do not assume either path.
find ~/.claude -name "permission-feedback.log" -newermt "-24 hours"
```
```
For each plugin, check <resolved-log-dir>/hooks.log size
Check review-history.jsonl entry count
Flag logs >1MB for rotation
```
### Step 7: Generate Report
Present structured dashboard with:
- Installed plugins table (name, version, hooks built, hook count, status)
- Continuity system status
- Environment status
- Hook deduplication check
- Log health
- Prioritized recommendations
## Output Format
```markdown
## Plugin System Diagnostics
### Installed Plugins (X/5)
| Plugin | Version | Install path | Hooks | Status |
|--------|---------|--------------|-------|--------|
| ctk | 1.3.2 | resolves | 22 built | OK |
| etk | 1.0.4 | resolves | 2 built | OK |
...
A plugin whose install path is `DANGLING` is reported as an inconsistent record and paired with the
load check — the other columns stay as measured, since a dangling path does not by itself mean the
plugin is unloaded.
### System Checks
| Check | Status |
|-------|--------|
| Install paths | All resolve / N DANGLING (record vs disk — confirm with the load check) |
| Continuity setup | OK |
| Context monitor | OK / NOT CONFIGURED / CONFLICT (statusLine runs <program>) |
| Last session | Clean / Stale |
| Node.js | vXX.x |
| VCS CLI | glab / gh / none |
### Recommendations
[Priority list or "All systems healthy"]
```
## Difference from Other Diagnostics
| Command | What it checks |
|---------|----------------|
| `/doctor` | Plugin installation, hook builds, environment, cross-plugin health |
| `/check-maintenance` | Continuity file sizes (ledger, handoffs, archives) |
| `/continuity-metrics` | Current session state (dirty tracking, heartbeat) |
## Related
- `/check-maintenance` for continuity file health
- `/continuity-metrics` for session state
- `/setup-continuity` to initialize continuity system
- `/setup-context-monitor` to configure context warnings