Skip to content

Nightwatch dispatches slash commands into sessions blind: an unknown one no-ops silently, and .claude/commands is the wrong place to check whether it exists #552

Description

@zionts

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions