Skip to content

bug: phase label slugs truncated to empty string for phases 9+ #100

@snipcodeit

Description

@snipcodeit

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:

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

  1. Run /mgw:project on a project that generates 4+ milestones with 9+ total phases.
  2. Observe the GitHub labels applied to issues in phases 9 and beyond.
  3. Labels will be phase:N- (empty slug) instead of phase: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)
fi

Key changes:

  1. Separates the fallback into an explicit if [ -z ... ] check — avoids relying on || which does not trigger when head exits 0.
  2. Replaces ${VAR,,} with tr '[:upper:]' '[:lower:]' — POSIX-compatible, works in both bash and ZSH.
  3. Adds tr -cd 'a-z0-9-' to strip any special characters from the slug.
  4. The guard [ -n "$PHASE_NAME" ] prevents the fallback from running when PHASE_NAME itself 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)
fi

Upstream 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- through phase:15- created and applied to 14 issues on TJ-Dev-Studio/proto1
  • Correct labels (phase:9-daily-and-weekly-engagement etc.) 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:project in extend mode (second extension run)

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions