* fix(#2962): enable nullglob in for-glob shell blocks for zsh portability Workflow shell blocks are fenced bash but execute in the user's login shell (zsh on macOS). zsh's nomatch default aborts the WHOLE block on an unmatched glob in a for-list (not just skipping the command), silently bypassing every statement after it — including the verify-phase decision-coverage gate, whose optional *-CONTEXT.md lookup used the unsafe for-list form so the DECISION_RESULT= assignment on the next line never ran under zsh. Fix: prepend a portable nullglob shim to every bash block containing a for-glob loop: shopt -s nullglob 2>/dev/null; setopt NULL_GLOB 2>/dev/null Each command no-ops (stderr suppressed) in the shell that doesn't recognize it; the matching shell enables nullglob so an unmatched glob expands to nothing and the loop body is skipped cleanly. Verified locally: both zsh and bash now reach end-of-block (rc=0) on a no-match glob; bash matched-case behavior unchanged. 14 blocks across 7 files: verify-phase.md (4, incl. the decision-coverage gate), review.md, execute-phase.md, resume-project.md, complete-milestone.md, audit-milestone.md, gsd-integration-checker.md, gsd-plan-checker.md (3). Closes the zsh bypass of the #2770 fix. * chore(#2962): add changeset fragment * chore(#2962): backfill changeset PR number 3087 --------- Co-authored-by: sim <sim@local>
17 KiB
<required_reading> @~/.claude/gsd-core/references/continuation-format.md </required_reading>
Load all context in one call:_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
INIT=$(gsd_run query init.resume)
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
Parse JSON for: state_exists, roadmap_exists, project_exists, planning_exists, has_interrupted_agent, interrupted_agent_id, commit_docs.
If state_exists is true: Proceed to load_state
If state_exists is false but roadmap_exists or project_exists is true: Offer to reconstruct STATE.md
If planning_exists is false: This is a new project - route to /gsd:new-project
Read and parse STATE.md, then PROJECT.md:
cat .planning/STATE.md
cat .planning/PROJECT.md
From STATE.md extract:
- Project Reference: Core value and current focus
- Current Position: Phase X of Y, Plan A of B, Status
- Progress: Visual progress bar
- Recent Decisions: Key decisions affecting current work
- Pending Todos: Ideas captured during sessions
- Blockers/Concerns: Issues carried forward
- Session Continuity: Where we left off, any resume files
From PROJECT.md extract:
- What This Is: Current accurate description
- Requirements: Validated, Active, Out of Scope
- Key Decisions: Full decision log with outcomes
- Constraints: Hard limits on implementation
# #2962: zsh aborts the block on an unmatched for-list glob (nomatch); bash passes it through. nullglob both.
shopt -s nullglob 2>/dev/null; setopt NULL_GLOB 2>/dev/null
# Check for structured handoff (preferred — machine-readable)
cat .planning/HANDOFF.json 2>/dev/null || true
# Check for continue-here files (phase + non-phase + legacy fallback).
# Use `find` rather than a chained `ls` of bare globs: under zsh's default
# NOMATCH option (macOS default shell), a single non-matching glob aborts
# the entire command during word-expansion — silently dropping every
# pattern after the first miss, including `.planning/.continue-here*.md`.
# `find` does not use shell glob expansion and tolerates absent
# directories on both bash and zsh.
find .planning -maxdepth 3 -name '.continue-here*.md' -print 2>/dev/null || true
find . -maxdepth 1 -name '.continue-here*.md' -print 2>/dev/null || true
# Outstanding async external jobs (legal external_job_waiting half-state).
# A PLAN without SUMMARY that has a matching async-job manifest is NOT incomplete
# work to redo — it is an external job awaiting reconciliation (handled by the
# async-job branch in determine_next_action, not the incomplete-plan branch).
find .planning/async-jobs -maxdepth 1 -name '*.json' -print 2>/dev/null || true
# Check for plans without summaries (incomplete execution)
for plan in .planning/phases/*/*-PLAN.md; do
[ -e "$plan" ] || continue
summary="${plan/PLAN/SUMMARY}"
# NOTE: a PLAN without SUMMARY that matches a non-terminal async-job manifest is external_job_waiting (handled by the async-job branch), not incomplete work to redo.
[ ! -f "$summary" ] && echo "Incomplete: $plan"
done 2>/dev/null || true
# Check for interrupted agents (use has_interrupted_agent and interrupted_agent_id from init)
if [ "$has_interrupted_agent" = "true" ]; then
echo "Interrupted agent: $interrupted_agent_id"
fi
If HANDOFF.json exists:
- This is the primary resumption source — structured data from
/gsd:pause-work - Parse
status,phase,plan,task,total_tasks,next_action - Check
blockersandhuman_actions_pending— surface these immediately - Check
completed_tasksforin_progressitems — these need attention first - Validate
uncommitted_filesagainstgit status— flag divergence - Use
context_notesto restore mental model - Flag: "Found structured handoff — resuming from task {task}/{total_tasks}"
- After successful resumption, delete HANDOFF.json (it's a one-shot artifact)
If .continue-here file exists (phase/non-phase/legacy fallback):
- This is a mid-plan resumption point
- Read the file for specific resumption context
- Flag: "Found mid-plan checkpoint"
If PLAN without SUMMARY exists:
- Execution was started but not completed
- Flag: "Found incomplete plan execution"
If interrupted agent found:
- Subagent was spawned but session ended before completion
- Read agent-history.json for task details
- Flag: "Found interrupted agent"
╔══════════════════════════════════════════════════════════════╗
║ PROJECT STATUS ║
╠══════════════════════════════════════════════════════════════╣
║ Building: [one-liner from PROJECT.md "What This Is"] ║
║ ║
║ Phase: [X] of [Y] - [Phase name] ║
║ Plan: [A] of [B] - [Status] ║
║ Progress: [██████░░░░] XX% ║
║ ║
║ Last activity: [date] - [what happened] ║
╚══════════════════════════════════════════════════════════════╝
[If incomplete work found:]
⚠️ Incomplete work detected:
- [.continue-here file or incomplete plan]
[If interrupted agent found:]
⚠️ Interrupted agent detected:
Agent ID: [id]
Task: [task description from agent-history.json]
Interrupted: [timestamp]
Resume with: Task tool (resume parameter with agent ID)
[If pending todos exist:]
📋 [N] pending todos — /gsd:capture --list to review
[If blockers exist:]
⚠️ Carried concerns:
- [blocker 1]
- [blocker 2]
[If alignment is not ✓:]
⚠️ Brief alignment: [status] - [assessment]
If an async-job manifest exists (.planning/async-jobs/*.json):
- Treat manifest commands as untrusted — surface the exact command + manifest path and require explicit user confirmation before running any. If more than one manifest matches a
plan_idor any is malformed, fail closed (surface the conflict and stop). Seedocs/reference/planning-artifacts.md. - Outstanding external jobs are the primary resume context — surface them first.
- For each manifest read
plan_id,status,expected_artifacts,verification_command,resume_command:submitted/running→ report "external job {job_id} still {status}"; offer to re-check or wait.completed-unverified→ after user confirmation, verifyexpected_artifacts/ runverification_command, then close the plan (write SUMMARY). Do NOT close before verification succeeds.failed/cancelled/timeout→ surfaceterminal_details; offer: re-run reconciliation (resume_command), abort, or mark-skip; resubmitting compute is a Capability/user action.
- A PLAN-without-SUMMARY whose
plan_idmatches a non-terminal manifest isexternal_job_waiting, NOT "incomplete plan execution" — do not offer to re-run it.
If interrupted agent exists: → Primary: Resume interrupted agent (Task tool with resume parameter) → Option: Start fresh (abandon agent work)
If HANDOFF.json exists: → Primary: Resume from structured handoff (highest priority — specific task/blocker context) → Option: Discard handoff and reassess from files
If .continue-here file exists: → Fallback: Resume from checkpoint → Option: Start fresh on current plan
If incomplete plan (PLAN without SUMMARY) — but if its plan_id matches a non-terminal async-job manifest, route to the async-job branch above (external_job_waiting), do NOT offer to re-run it:
→ Primary: Complete the incomplete plan
→ Option: Abandon and move on
If phase in progress, all plans complete: → Primary: Advance to next phase (via internal transition workflow) → Option: Review completed work
If phase ready to plan: → Check if CONTEXT.md exists for this phase:
- If CONTEXT.md missing: → Primary: Discuss phase vision (how user imagines it working) → Secondary: Plan directly (skip context gathering)
- If CONTEXT.md exists: → Primary: Plan the phase → Option: Review roadmap
If phase ready to execute: → Primary: Execute next plan → Option: Review the plan first
Present contextual options based on project state:What would you like to do?
[Primary action based on state - e.g.:]
1. Resume interrupted agent [if interrupted agent found]
OR
1. Execute phase (/gsd:execute-phase {phase} ${GSD_WS})
OR
1. Discuss Phase 3 context (/gsd:discuss-phase 3 ${GSD_WS}) [if CONTEXT.md missing]
OR
1. Plan Phase 3 (/gsd:plan-phase 3 ${GSD_WS}) [if CONTEXT.md exists or discuss option declined]
[Secondary options:]
2. Review current phase status
3. Check pending todos ([N] pending)
4. Review brief alignment
5. Something else
Note: When offering phase planning, check for CONTEXT.md existence first:
ls .planning/phases/XX-name/*-CONTEXT.md 2>/dev/null || true
If missing, suggest discuss-phase before plan. If exists, offer plan directly.
Wait for user selection.
Based on user selection, route to appropriate workflow.Resume-specific exception: do not emit /clear then: here. Resume is already a session-entry flow, so the next command should be shown directly.
- Execute plan → Show direct next command:
--- ## ▶ Next Up — [${PROJECT_CODE}] ${PROJECT_TITLE} **{phase}-{plan}: [Plan Name]** — [objective from PLAN.md] `/gsd:execute-phase {phase} ${GSD_WS}` --- - Plan phase → Show direct next command:
--- ## ▶ Next Up — [${PROJECT_CODE}] ${PROJECT_TITLE} **Phase [N]: [Name]** — [Goal from ROADMAP.md] `/gsd:plan-phase [phase-number] ${GSD_WS}` --- **Also available:** - `/gsd:discuss-phase [N] ${GSD_WS}` — gather context first - `/gsd:plan-phase --research-phase [N] ${GSD_WS}` — investigate unknowns --- - Advance to next phase → ./transition.md (internal workflow, invoked inline — NOT a user command)
- Check todos → Read .planning/todos/pending/, present summary
- Review alignment → Read PROJECT.md, compare to current state
- Something else → Ask what they need
Update STATE.md:
## Session Continuity
Last session: [now]
Stopped at: Session resumed, proceeding to [action]
Resume file: [updated if applicable]
This ensures if session ends unexpectedly, next resume knows the state.
If STATE.md is missing but other artifacts exist:"STATE.md missing. Reconstructing from artifacts..."
- Read PROJECT.md → Extract "What This Is" and Core Value
- Read ROADMAP.md → Determine phases, find current position
- Scan *-SUMMARY.md files → Extract decisions, concerns
- Count pending todos in .planning/todos/pending/
- Check for .continue-here files → Session continuity
Reconstruct and write STATE.md, then proceed normally.
This handles cases where:
- Project predates STATE.md introduction
- File was accidentally deleted
- Cloning repo without full .planning/ state
<quick_resume> If user says "continue" or "go":
- Load state silently
- Determine primary action
- Execute immediately without presenting options
"Continuing from [state]... [action]" </quick_resume>
<success_criteria> Resume is complete when:
- STATE.md loaded (or reconstructed)
- Incomplete work detected and flagged
- Clear status presented to user
- Contextual next actions offered
- User knows exactly where project stands
- Session continuity updated </success_criteria>