-
-
Notifications
You must be signed in to change notification settings - Fork 0
Description
Summary
When /mgw:project creates issues for a project with many phases, phase labels are generated with malformed slugs starting from a certain phase number. Observed on TJ-Dev-Studio/proto1:
- Issues Implement --headless flag in mgw:run and mgw:issue #127–Write unit tests for lib/state.cjs #133 (phases 7–8) → labels
phase:7-foundation-fixes,phase:8-match-experience✓ - Issues feat: add vision-researcher domain expansion agent to mgw:project #109–feat: wire vision-condenser handoff into gsd:new-project Task spawn #112 (phases 9–10) → labels
phase:9-,phase:10-✗ - Issues feat: replace current_milestone with active_gsd_milestone in project.json #113–Design headless mode contract for mgw CLI #126 (phases 11–15) → labels
phase:11-,phase:12-, ...phase:15-✗
The correct labels (phase:9-daily-and-weekly-engagement, etc.) were created as GitHub labels in a subsequent run, but the issues were never updated.
Root Cause
File: commands/project.md
Step: create_issues (Pass 1b)
Line 339:
PHASE_SLUG=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs generate-slug "${PHASE_NAME}" --raw 2>/dev/null | head -c 40 || echo "${PHASE_NAME,,}" | tr ' ' '-' | cut -c1-40)Mechanism
The bug has two interacting problems:
Problem 1 — Silent empty output when PHASE_NAME is empty:
When PHASE_NAME is empty or unset (see Problem 2 below), gsd-tools generate-slug writes its error to stderr and nothing to stdout, then exits with status 1:
$ node gsd-tools.cjs generate-slug "" --raw 2>/dev/null | head -c 40
(no output)
Because head -c 40 reads zero bytes but exits with status 0, the pipeline exit status is 0. The || fallback never fires. The command substitution captures an empty string, so PHASE_SLUG="".
Problem 2 — PHASE_NAME is unset in later bash calls:
Claude Code executes the multi-step loop in the create_issues step across separate Bash tool invocations. Shell variables (including PHASE_NAME) set in one invocation do not persist to the next. For the phases processed in later invocations (typically starting at the second milestone boundary), PHASE_NAME is not in scope when the PHASE_SLUG generation runs, producing an empty string.
Problem 3 — Fallback uses bash-only syntax (ZSH incompatibility):
Even if the || fallback were to run, the fallback expression echo "${PHASE_NAME,,}" uses bash parameter expansion lowercase operator ${VAR,,}, which is not supported in ZSH. On systems where the default shell is /usr/bin/zsh, this causes bad substitution and silently produces an empty string inside the $() subshell. This means both the primary path and the fallback path can produce empty slugs on ZSH systems.
Why phases 7–8 work but 9+ don't
Phases 7–8 were the first two phases of the first extend milestone. They were processed in the same Bash call where PHASE_NAME was freshly extracted, so the variable was in scope. Phases 9+ belong to the second and subsequent extend milestones — processed in new Bash invocations where PHASE_NAME was not carried over.
Steps to Reproduce
- Run
/mgw:projecton a project that generates 4+ milestones with 9+ total phases. - Observe the GitHub labels applied to issues in phases 9 and beyond.
- Labels will be
phase:N-(empty slug) instead ofphase:N-<slug>.
Alternatively, simulate the broken PHASE_NAME path in ZSH:
PHASE_NAME=""
PHASE_SLUG=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs generate-slug "${PHASE_NAME}" --raw 2>/dev/null | head -c 40 || echo "${PHASE_NAME,,}" | tr ' ' '-' | cut -c1-40)
echo "PHASE_SLUG: '${PHASE_SLUG}'"
# Output: PHASE_SLUG: ''Proposed Fix
Line 339 — explicit check + ZSH-compatible lowercasing:
PHASE_SLUG=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs generate-slug "${PHASE_NAME}" --raw 2>/dev/null | head -c 40)
if [ -z "$PHASE_SLUG" ] && [ -n "$PHASE_NAME" ]; then
PHASE_SLUG=$(echo "$PHASE_NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd 'a-z0-9-' | cut -c1-40)
fiKey changes:
- Separates the fallback into an explicit
if [ -z ... ]check — avoids relying on||which does not trigger whenheadexits 0. - Replaces
${VAR,,}withtr '[:upper:]' '[:lower:]'— POSIX-compatible, works in both bash and ZSH. - Adds
tr -cd 'a-z0-9-'to strip any special characters from the slug. - The guard
[ -n "$PHASE_NAME" ]prevents the fallback from running whenPHASE_NAMEitself is empty (the upstream issue that should be caught separately with a hard error).
Same fix also needed on line 378 for ISSUE_SLUG:
ISSUE_SLUG=$(node ~/.claude/get-shit-done/bin/gsd-tools.cjs generate-slug "${ISSUE_TITLE}" --raw 2>/dev/null | head -c 40)
if [ -z "$ISSUE_SLUG" ] && [ -n "$ISSUE_TITLE" ]; then
ISSUE_SLUG=$(echo "$ISSUE_TITLE" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd 'a-z0-9-' | cut -c1-40)
fiUpstream fix: The PHASE_NAME extraction via python3 and the PHASE_SLUG generation must occur in the same atomic bash invocation. Splitting them across separate Bash tool calls allows the variable to go out of scope.
Observed Impact
- GitHub labels
phase:9-throughphase:15-created and applied to 14 issues onTJ-Dev-Studio/proto1 - Correct labels (
phase:9-daily-and-weekly-engagementetc.) exist as orphaned labels but are NOT applied to the affected issues - All affected issues require manual re-labeling
Both commands/project.md (in repo) and ~/.claude/commands/mgw/project.md (installed copy) are identical and both contain the bug.
Environment
- Shell:
/usr/bin/zsh(ZSH 5.x) - Platform: Arch Linux x86_64
- Claude Code model: claude-sonnet-4-6
- MGW invocation:
/mgw:projectin extend mode (second extension run)