Two halves of the same footgun. Surfaced while fixing #551 (stale nightwatch facts), filed rather than fixed there because it's a different bug.
1. The dispatch is blind
judge.py reads a per-repo hook's advance_skill and sends it into the owner's terminal as a slash command:
adv = a.get("advance_skill")
txt = f"nightwatch: PR #{pr} not yet claude-APPROVED ({verdict}). Run `/{adv}` to drive it to APPROVED on the current SHA. …"
if not sat and dispatch(owner["tid"], txt, act): acted += 1; tag = "DISPATCHED"
Nothing checks that /{adv} resolves in the receiving worktree. Skills are repo-scoped — they live in that repo's .claude/skills/ — so a skill bound to one repo's hook and dispatched into a session checked out on another repo names something that isn't there.
The reported behavior (per Adam, and the assumption this issue rests on — worth confirming before building the fix): Claude Code drops an unknown slash command without creating a user message. If so, the failure is invisible from both ends. The judge counts the send as DISPATCHED and moves on; the worker never sees a prompt at all. There's no error, no no-op marker, no divergence between "nudged and ignored" and "never nudged" — which is precisely the confusion the wake-verifier work was built to eliminate for PR state.
Worth noting the blast radius is wider than advance_skill: any slash command TBD composes into a tbd terminal send has the same property.
2. "Does this command exist?" is not answerable by searching .claude/commands
This is the half that cost real work, and it's the more generalizable defect.
Adam searched .claude/commands across two repos, the TBD plugin dir, and every profile dir, found nothing, and concluded /closeout and /anything-left did not exist — then wrote that into a shift summary as a finding.
They exist. They're skills, not commands:
.claude/skills/closeout/SKILL.md + references/, scripts/
.claude/skills/anything-left/SKILL.md + references/, scripts/
Both committed to longeye-ai/monorepo on origin/main, both user-invocable: true, both with explicit patterns: entries (/closeout, /anything-left, plus natural-language triggers like "am I done", "is it safe to delete this worktree"). They were never commands, either — git log --all --diff-filter=ADR -- '.claude/commands/*closeout*' '.claude/commands/*anything*' returns nothing, so there is no rename to find and no deletion to blame.
The search wasn't sloppy; the search space was wrong, and nothing anywhere says so. Two directories back the same /name namespace, and the smaller one is the one everybody looks in:
| repo |
.claude/commands/*.md |
.claude/skills/*/ |
of which user-invocable: true |
longeye-ai/monorepo |
23 |
91 |
60 |
cheapsteak/tbd |
directory does not exist |
4 |
— |
So in the monorepo, checking only .claude/commands inspects 23 of ~83 candidate slash commands and misses about 72% of the surface. In this repo it inspects zero of them, because there is no commands directory at all — skills are the only home.
(Frontmatter is not a reliable discriminator either: TBD's own four skills carry no user-invocable key and still surface as /tbd-brainstorming and friends. Whatever the exact rule, "grep for user-invocable: true" isn't it.)
Why the two halves belong in one issue
A presence check is the fix for both, and it can't be written against .claude/commands alone or it reproduces the same wrong answer mechanically — at fleet scale, with nobody reading it.
Sketch, not a design
- Resolve a slash command before dispatch by checking both
.claude/commands/<name>.md and .claude/skills/<name>/SKILL.md in the receiving worktree's repo (plus installed plugin skills).
- On a miss, don't send. Escalate to
for-adam.md as an unresolvable dispatch, naming the command, the target repo, and the hook that bound it — a config error, not a worker problem.
- Don't count an unresolved dispatch as
DISPATCHED in the tick report. That number currently overstates what happened.
- Longer-term: a hook binding a skill to a repo whose worktrees can't resolve it is checkable at bind time, not just at send time.
Status
- Verified: the dispatch site, the skills' existence and location on
origin/main, the absence of any command-file history for them, and the counts above.
- Assumed, from Adam's report: that an unknown slash command is dropped without creating a user message. If it instead lands as literal text, the failure mode changes (a worker gets a nonsense prompt rather than nothing) but the fix does not.
Two halves of the same footgun. Surfaced while fixing #551 (stale nightwatch facts), filed rather than fixed there because it's a different bug.
1. The dispatch is blind
judge.pyreads a per-repo hook'sadvance_skilland sends it into the owner's terminal as a slash command:Nothing checks that
/{adv}resolves in the receiving worktree. Skills are repo-scoped — they live in that repo's.claude/skills/— so a skill bound to one repo's hook and dispatched into a session checked out on another repo names something that isn't there.The reported behavior (per Adam, and the assumption this issue rests on — worth confirming before building the fix): Claude Code drops an unknown slash command without creating a user message. If so, the failure is invisible from both ends. The judge counts the send as
DISPATCHEDand moves on; the worker never sees a prompt at all. There's no error, no no-op marker, no divergence between "nudged and ignored" and "never nudged" — which is precisely the confusion the wake-verifier work was built to eliminate for PR state.Worth noting the blast radius is wider than
advance_skill: any slash command TBD composes into atbd terminal sendhas the same property.2. "Does this command exist?" is not answerable by searching
.claude/commandsThis is the half that cost real work, and it's the more generalizable defect.
Adam searched
.claude/commandsacross two repos, the TBD plugin dir, and every profile dir, found nothing, and concluded/closeoutand/anything-leftdid not exist — then wrote that into a shift summary as a finding.They exist. They're skills, not commands:
Both committed to
longeye-ai/monorepoonorigin/main, bothuser-invocable: true, both with explicitpatterns:entries (/closeout,/anything-left, plus natural-language triggers like "am I done", "is it safe to delete this worktree"). They were never commands, either —git log --all --diff-filter=ADR -- '.claude/commands/*closeout*' '.claude/commands/*anything*'returns nothing, so there is no rename to find and no deletion to blame.The search wasn't sloppy; the search space was wrong, and nothing anywhere says so. Two directories back the same
/namenamespace, and the smaller one is the one everybody looks in:.claude/commands/*.md.claude/skills/*/user-invocable: truelongeye-ai/monorepocheapsteak/tbdSo in the monorepo, checking only
.claude/commandsinspects 23 of ~83 candidate slash commands and misses about 72% of the surface. In this repo it inspects zero of them, because there is no commands directory at all — skills are the only home.(Frontmatter is not a reliable discriminator either: TBD's own four skills carry no
user-invocablekey and still surface as/tbd-brainstormingand friends. Whatever the exact rule, "grep foruser-invocable: true" isn't it.)Why the two halves belong in one issue
A presence check is the fix for both, and it can't be written against
.claude/commandsalone or it reproduces the same wrong answer mechanically — at fleet scale, with nobody reading it.Sketch, not a design
.claude/commands/<name>.mdand.claude/skills/<name>/SKILL.mdin the receiving worktree's repo (plus installed plugin skills).for-adam.mdas an unresolvable dispatch, naming the command, the target repo, and the hook that bound it — a config error, not a worker problem.DISPATCHEDin the tick report. That number currently overstates what happened.Status
origin/main, the absence of any command-file history for them, and the counts above.