From 58c2b1f502f35659dfd9d8005ecbe2a20e674c75 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 23 Mar 2026 20:20:15 -0400 Subject: [PATCH] fix: Windows shell robustness, project_root detection, and hook stdin safety (#1343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address 4 root causes of Windows + Claude Code reliability issues: 1. Workflow shell robustness: add || true guards to informational commands (ls, grep, find, cat) that return non-zero on "no results", preventing workflow step failures under strict execution models. Guard glob loops with [ -e "$var" ] || continue to handle empty glob expansion. 2. Hook stdin handling: replace readFileSync('/dev/stdin') with async process.stdin + timeout in agent templates (gsd-verifier.md). Existing JS hooks already have timeout guards. 3. project_root detection: fix isInsideGitRepo() to check .git at the candidate parent level (not just below it), enabling correct detection when .git and .planning/ are siblings at the same directory level — the common single-repo case from a subdirectory. 4. @file: handoff: add missing @file: handlers to autonomous.md and manager.md workflows that call gsd-tools init but lacked the handler for large output payloads. Fixes #1343 Co-Authored-By: Claude Opus 4.6 (1M context) --- agents/gsd-verifier.md | 2 +- get-shit-done/bin/lib/core.cjs | 8 +- get-shit-done/workflows/add-tests.md | 4 +- get-shit-done/workflows/add-todo.md | 2 +- get-shit-done/workflows/audit-milestone.md | 1 + get-shit-done/workflows/autonomous.md | 17 +- get-shit-done/workflows/cleanup.md | 4 +- get-shit-done/workflows/complete-milestone.md | 7 +- .../workflows/discuss-phase-assumptions.md | 12 +- get-shit-done/workflows/discuss-phase.md | 20 +- get-shit-done/workflows/execute-plan.md | 10 +- get-shit-done/workflows/health.md | 2 +- get-shit-done/workflows/manager.md | 2 + get-shit-done/workflows/pause-work.md | 4 +- .../workflows/plan-milestone-gaps.md | 2 +- get-shit-done/workflows/plan-phase.md | 4 +- get-shit-done/workflows/plant-seed.md | 2 +- get-shit-done/workflows/progress.md | 10 +- get-shit-done/workflows/research-phase.md | 2 +- get-shit-done/workflows/resume-project.md | 9 +- get-shit-done/workflows/transition.md | 14 +- get-shit-done/workflows/verify-phase.md | 6 +- get-shit-done/workflows/verify-work.md | 4 +- tests/core.test.cjs | 32 +++ tests/windows-robustness.test.cjs | 204 ++++++++++++++++++ 25 files changed, 314 insertions(+), 70 deletions(-) create mode 100644 tests/windows-robustness.test.cjs diff --git a/agents/gsd-verifier.md b/agents/gsd-verifier.md index 55a494f2d..a2fc9d280 100644 --- a/agents/gsd-verifier.md +++ b/agents/gsd-verifier.md @@ -390,7 +390,7 @@ Anti-pattern scanning (Step 7) checks for code smells. Behavioral spot-checks go ```bash # API endpoint returns non-empty data -curl -s http://localhost:$PORT/api/$ENDPOINT 2>/dev/null | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); process.exit(Array.isArray(d) ? (d.length > 0 ? 0 : 1) : (Object.keys(d).length > 0 ? 0 : 1))" +curl -s http://localhost:$PORT/api/$ENDPOINT 2>/dev/null | node -e "let b='';process.stdin.setEncoding('utf8');process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{const d=JSON.parse(b);process.exit(Array.isArray(d)?(d.length>0?0:1):(Object.keys(d).length>0?0:1))})" # CLI command produces expected output node $CLI_PATH --help 2>&1 | grep -q "$EXPECTED_SUBCOMMAND" diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 9785cfc74..b6d9edc3b 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -58,13 +58,15 @@ function findProjectRoot(startDir) { const root = path.parse(resolved).root; const homedir = require('os').homedir(); - // Check if startDir or any of its ancestors (up to but not including a + // Check if startDir or any of its ancestors (up to AND including the // candidate project root) contains a .git directory. This handles both - // `backend/` (direct sub-repo) and `backend/src/modules/` (nested inside). + // `backend/` (direct sub-repo) and `backend/src/modules/` (nested inside), + // as well as the common case where .git lives at the same level as .planning/. function isInsideGitRepo(candidateParent) { let d = resolved; - while (d !== candidateParent && d !== root) { + while (d !== root) { if (fs.existsSync(path.join(d, '.git'))) return true; + if (d === candidateParent) break; d = path.dirname(d); } return false; diff --git a/get-shit-done/workflows/add-tests.md b/get-shit-done/workflows/add-tests.md index f2687da94..1e586a274 100644 --- a/get-shit-done/workflows/add-tests.md +++ b/get-shit-done/workflows/add-tests.md @@ -146,7 +146,7 @@ find . -type d -name "*test*" -o -name "*spec*" -o -name "*__tests__*" 2>/dev/nu # Find existing test files for convention matching find . -type f \( -name "*.test.*" -o -name "*.spec.*" -o -name "*Tests.fs" -o -name "*Test.fs" \) 2>/dev/null | head -20 # Check for test runners -ls package.json *.sln 2>/dev/null +ls package.json *.sln 2>/dev/null || true ``` Identify: @@ -243,7 +243,7 @@ For each approved E2E test: 1. **Check for existing tests** covering the same scenario: ```bash - grep -r "{scenario keyword}" {e2e test directory} 2>/dev/null + grep -r "{scenario keyword}" {e2e test directory} 2>/dev/null || true ``` If found, extend rather than duplicate. diff --git a/get-shit-done/workflows/add-todo.md b/get-shit-done/workflows/add-todo.md index b030a4edb..3226830e8 100644 --- a/get-shit-done/workflows/add-todo.md +++ b/get-shit-done/workflows/add-todo.md @@ -63,7 +63,7 @@ Use existing area from step 2 if similar match exists. ```bash # Search for key words from title in existing todos -grep -l -i "[key words from title]" .planning/todos/pending/*.md 2>/dev/null +grep -l -i "[key words from title]" .planning/todos/pending/*.md 2>/dev/null || true ``` If potential duplicate found: diff --git a/get-shit-done/workflows/audit-milestone.md b/get-shit-done/workflows/audit-milestone.md index a77532c34..07998ec6d 100644 --- a/get-shit-done/workflows/audit-milestone.md +++ b/get-shit-done/workflows/audit-milestone.md @@ -105,6 +105,7 @@ For each phase's VERIFICATION.md, extract the expanded requirements table: For each phase's SUMMARY.md, extract `requirements-completed` from YAML frontmatter: ```bash for summary in .planning/phases/*-*/*-SUMMARY.md; do + [ -e "$summary" ] || continue node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" summary-extract "$summary" --fields requirements_completed --pick requirements_completed done ``` diff --git a/get-shit-done/workflows/autonomous.md b/get-shit-done/workflows/autonomous.md index f293c8323..390298ad4 100644 --- a/get-shit-done/workflows/autonomous.md +++ b/get-shit-done/workflows/autonomous.md @@ -29,6 +29,7 @@ Bootstrap via milestone-level init: ```bash INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init milestone-op) +if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi ``` Parse JSON for: `milestone_version`, `milestone_name`, `phase_count`, `completed_phases`, `roadmap_exists`, `state_exists`, `commit_docs`. @@ -351,9 +352,9 @@ Read project-level and prior phase context to avoid re-asking decided questions. **Read project files:** ```bash -cat .planning/PROJECT.md 2>/dev/null -cat .planning/REQUIREMENTS.md 2>/dev/null -cat .planning/STATE.md 2>/dev/null +cat .planning/PROJECT.md 2>/dev/null || true +cat .planning/REQUIREMENTS.md 2>/dev/null || true +cat .planning/STATE.md 2>/dev/null || true ``` Extract from these: @@ -364,7 +365,7 @@ Extract from these: **Read all prior CONTEXT.md files:** ```bash -find .planning/phases -name "*-CONTEXT.md" 2>/dev/null | sort +(find .planning/phases -name "*-CONTEXT.md" 2>/dev/null || true) | sort ``` For each CONTEXT.md where phase number < current phase: @@ -398,7 +399,7 @@ Lightweight codebase scan to inform grey area identification and proposals. Keep **Check for existing codebase maps:** ```bash -ls .planning/codebase/*.md 2>/dev/null +ls .planning/codebase/*.md 2>/dev/null || true ``` **If codebase maps exist:** Read the most relevant ones (CONVENTIONS.md, STRUCTURE.md, STACK.md based on phase type). Extract reusable components, established patterns, integration points. Skip to building context below. @@ -408,8 +409,8 @@ ls .planning/codebase/*.md 2>/dev/null Extract key terms from the phase goal. Search for related files: ```bash -grep -rl "{term1}\|{term2}" src/ app/ --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" 2>/dev/null | head -10 -ls src/components/ src/hooks/ src/lib/ src/utils/ 2>/dev/null +grep -rl "{term1}\|{term2}" src/ app/ --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" 2>/dev/null | head -10 || true +ls src/components/ src/hooks/ src/lib/ src/utils/ 2>/dev/null || true ``` Read the 3-5 most relevant files to understand existing patterns. @@ -721,7 +722,7 @@ Skill(skill="gsd:complete-milestone", args="${milestone_version}") After complete-milestone returns, verify it produced output: ```bash -ls .planning/milestones/v${milestone_version}-ROADMAP.md 2>/dev/null +ls .planning/milestones/v${milestone_version}-ROADMAP.md 2>/dev/null || true ``` If the archive file does not exist, go to handle_blocker: "Complete milestone did not produce expected archive files." diff --git a/get-shit-done/workflows/cleanup.md b/get-shit-done/workflows/cleanup.md index c1f772e77..ea9ee880f 100644 --- a/get-shit-done/workflows/cleanup.md +++ b/get-shit-done/workflows/cleanup.md @@ -27,7 +27,7 @@ Extract each milestone version (e.g., v1.0, v1.1, v2.0). Check which milestone archive dirs already exist: ```bash -ls -d .planning/milestones/v*-phases 2>/dev/null +ls -d .planning/milestones/v*-phases 2>/dev/null || true ``` Filter to milestones that do NOT already have a `-phases` archive directory. @@ -55,7 +55,7 @@ Extract phase numbers and names from the archived roadmap (e.g., Phase 1: Founda Check which of those phase directories still exist in `.planning/phases/`: ```bash -ls -d .planning/phases/*/ 2>/dev/null +ls -d .planning/phases/*/ 2>/dev/null || true ``` Match phase directories to milestone membership. Only include directories that still exist in `.planning/phases/`. diff --git a/get-shit-done/workflows/complete-milestone.md b/get-shit-done/workflows/complete-milestone.md index 67824029c..e95e7cb61 100644 --- a/get-shit-done/workflows/complete-milestone.md +++ b/get-shit-done/workflows/complete-milestone.md @@ -90,7 +90,7 @@ If user selects "Proceed anyway": note incomplete requirements in MILESTONES.md ```bash -cat .planning/config.json 2>/dev/null +cat .planning/config.json 2>/dev/null || true ``` @@ -129,7 +129,7 @@ Calculate milestone statistics: ```bash git log --oneline --grep="feat(" | head -20 git diff --stat FIRST_COMMIT..LAST_COMMIT | tail -1 -find . -name "*.swift" -o -name "*.ts" -o -name "*.py" | xargs wc -l 2>/dev/null +find . -name "*.swift" -o -name "*.ts" -o -name "*.py" | xargs wc -l 2>/dev/null || true git log --format="%ai" FIRST_COMMIT | tail -1 git log --format="%ai" LAST_COMMIT | head -1 ``` @@ -156,6 +156,7 @@ Extract one-liners from SUMMARY.md files using summary-extract: ```bash # For each phase in milestone, extract one-liner for summary in .planning/phases/*-*/*-SUMMARY.md; do + [ -e "$summary" ] || continue node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" summary-extract "$summary" --fields one_liner --pick one_liner done ``` @@ -446,7 +447,7 @@ rm .planning/REQUIREMENTS.md Check for existing retrospective: ```bash -ls .planning/RETROSPECTIVE.md 2>/dev/null +ls .planning/RETROSPECTIVE.md 2>/dev/null || true ``` **If exists:** Read the file, append new milestone section before the "## Cross-Milestone Trends" section. diff --git a/get-shit-done/workflows/discuss-phase-assumptions.md b/get-shit-done/workflows/discuss-phase-assumptions.md index 26d51a31b..94277175c 100644 --- a/get-shit-done/workflows/discuss-phase-assumptions.md +++ b/get-shit-done/workflows/discuss-phase-assumptions.md @@ -89,7 +89,7 @@ Exit workflow. Check if CONTEXT.md already exists using `has_context` from init. ```bash -ls ${phase_dir}/*-CONTEXT.md 2>/dev/null +ls ${phase_dir}/*-CONTEXT.md 2>/dev/null || true ``` **If exists:** @@ -134,9 +134,9 @@ Read project-level and prior phase context to avoid re-asking decided questions. **Step 1: Read project-level files** ```bash -cat .planning/PROJECT.md 2>/dev/null -cat .planning/REQUIREMENTS.md 2>/dev/null -cat .planning/STATE.md 2>/dev/null +cat .planning/PROJECT.md 2>/dev/null || true +cat .planning/REQUIREMENTS.md 2>/dev/null || true +cat .planning/STATE.md 2>/dev/null || true ``` Extract from these: @@ -146,7 +146,7 @@ Extract from these: **Step 2: Read all prior CONTEXT.md files** ```bash -find .planning/phases -name "*-CONTEXT.md" 2>/dev/null | sort +(find .planning/phases -name "*-CONTEXT.md" 2>/dev/null || true) | sort ``` For each CONTEXT.md where phase number < current phase: @@ -185,7 +185,7 @@ Lightweight scan of existing code to inform assumption generation. **Step 1: Check for existing codebase maps** ```bash -ls .planning/codebase/*.md 2>/dev/null +ls .planning/codebase/*.md 2>/dev/null || true ``` **If codebase maps exist:** Read relevant ones (CONVENTIONS.md, STRUCTURE.md, STACK.md). Extract reusable components, patterns, integration points. Skip to Step 3. diff --git a/get-shit-done/workflows/discuss-phase.md b/get-shit-done/workflows/discuss-phase.md index 8a1c4b541..da17dc700 100644 --- a/get-shit-done/workflows/discuss-phase.md +++ b/get-shit-done/workflows/discuss-phase.md @@ -160,7 +160,7 @@ Exit workflow. Check if CONTEXT.md already exists using `has_context` from init. ```bash -ls ${phase_dir}/*-CONTEXT.md 2>/dev/null +ls ${phase_dir}/*-CONTEXT.md 2>/dev/null || true ``` **If exists:** @@ -206,9 +206,9 @@ Read project-level and prior phase context to avoid re-asking decided questions **Step 1: Read project-level files** ```bash # Core project files -cat .planning/PROJECT.md 2>/dev/null -cat .planning/REQUIREMENTS.md 2>/dev/null -cat .planning/STATE.md 2>/dev/null +cat .planning/PROJECT.md 2>/dev/null || true +cat .planning/REQUIREMENTS.md 2>/dev/null || true +cat .planning/STATE.md 2>/dev/null || true ``` Extract from these: @@ -219,7 +219,7 @@ Extract from these: **Step 2: Read all prior CONTEXT.md files** ```bash # Find all CONTEXT.md files from phases before current -find .planning/phases -name "*-CONTEXT.md" 2>/dev/null | sort +(find .planning/phases -name "*-CONTEXT.md" 2>/dev/null || true) | sort ``` For each CONTEXT.md where phase number < current phase: @@ -300,7 +300,7 @@ Lightweight scan of existing code to inform gray area identification and discuss **Step 1: Check for existing codebase maps** ```bash -ls .planning/codebase/*.md 2>/dev/null +ls .planning/codebase/*.md 2>/dev/null || true ``` **If codebase maps exist:** Read the most relevant ones (CONVENTIONS.md, STRUCTURE.md, STACK.md based on phase type). Extract: @@ -316,12 +316,12 @@ Extract key terms from the phase goal (e.g., "feed" → "post", "card", "list"; ```bash # Find files related to phase goal terms -grep -rl "{term1}\|{term2}" src/ app/ --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" 2>/dev/null | head -10 +grep -rl "{term1}\|{term2}" src/ app/ --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" 2>/dev/null | head -10 || true # Find existing components/hooks -ls src/components/ 2>/dev/null -ls src/hooks/ 2>/dev/null -ls src/lib/ src/utils/ 2>/dev/null +ls src/components/ 2>/dev/null || true +ls src/hooks/ 2>/dev/null || true +ls src/lib/ src/utils/ 2>/dev/null || true ``` Read the 3-5 most relevant files to understand existing patterns. diff --git a/get-shit-done/workflows/execute-plan.md b/get-shit-done/workflows/execute-plan.md index 8d00afdf9..8faa0a886 100644 --- a/get-shit-done/workflows/execute-plan.md +++ b/get-shit-done/workflows/execute-plan.md @@ -27,8 +27,8 @@ If `.planning/` missing: error. ```bash # Use plans/summaries from INIT JSON, or list files -ls .planning/phases/XX-name/*-PLAN.md 2>/dev/null | sort -ls .planning/phases/XX-name/*-SUMMARY.md 2>/dev/null | sort +(ls .planning/phases/XX-name/*-PLAN.md 2>/dev/null || true) | sort +(ls .planning/phases/XX-name/*-SUMMARY.md 2>/dev/null || true) | sort ``` Find first PLAN without matching SUMMARY. Decimal phases supported (`01.1-hotfix/`): @@ -467,7 +467,7 @@ If .planning/codebase/ doesn't exist: skip. ```bash FIRST_TASK=$(git log --oneline --grep="feat({phase}-{plan}):" --grep="fix({phase}-{plan}):" --grep="test({phase}-{plan}):" --reverse | head -1 | cut -d' ' -f1) -git diff --name-only ${FIRST_TASK}^..HEAD 2>/dev/null +git diff --name-only ${FIRST_TASK}^..HEAD 2>/dev/null || true ``` Update only structural changes: new src/ dir → STRUCTURE.md | deps → STACK.md | file pattern → CONVENTIONS.md | API client → INTEGRATIONS.md | config → STACK.md | renamed → update paths. Skip code-only/bugfix/content changes. @@ -481,8 +481,8 @@ node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "" --files .planning If `USER_SETUP_CREATED=true`: display `⚠️ USER SETUP REQUIRED` with path + env/config tasks at TOP. ```bash -ls -1 .planning/phases/[current-phase-dir]/*-PLAN.md 2>/dev/null | wc -l -ls -1 .planning/phases/[current-phase-dir]/*-SUMMARY.md 2>/dev/null | wc -l +(ls -1 .planning/phases/[current-phase-dir]/*-PLAN.md 2>/dev/null || true) | wc -l +(ls -1 .planning/phases/[current-phase-dir]/*-SUMMARY.md 2>/dev/null || true) | wc -l ``` | Condition | Route | Action | diff --git a/get-shit-done/workflows/health.md b/get-shit-done/workflows/health.md index c8c6b4eeb..579558c52 100644 --- a/get-shit-done/workflows/health.md +++ b/get-shit-done/workflows/health.md @@ -168,7 +168,7 @@ When `--repair` is active, detect and clean up: # Check for stale task directories (older than 24 hours) TASKS_DIR="$HOME/.claude/tasks" if [ -d "$TASKS_DIR" ]; then - STALE_COUNT=$(find "$TASKS_DIR" -maxdepth 1 -type d -mtime +1 2>/dev/null | wc -l) + STALE_COUNT=$( (find "$TASKS_DIR" -maxdepth 1 -type d -mtime +1 2>/dev/null || true) | wc -l ) if [ "$STALE_COUNT" -gt 0 ]; then echo "⚠️ Found $STALE_COUNT stale task directories in ~/.claude/tasks/" echo " These are leftover from crashed subagent sessions." diff --git a/get-shit-done/workflows/manager.md b/get-shit-done/workflows/manager.md index 6ee575ab3..701c5ac9c 100644 --- a/get-shit-done/workflows/manager.md +++ b/get-shit-done/workflows/manager.md @@ -20,6 +20,7 @@ Bootstrap via manager init: ```bash INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init manager) +if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi ``` Parse JSON for: `milestone_version`, `milestone_name`, `phase_count`, `completed_count`, `in_progress_count`, `phases`, `recommended_actions`, `all_complete`, `waiting_signal`. @@ -53,6 +54,7 @@ Proceed to dashboard step. ```bash INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init manager) +if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi ``` Parse the full JSON. Build the dashboard display. diff --git a/get-shit-done/workflows/pause-work.md b/get-shit-done/workflows/pause-work.md index ccdba267d..5e60ad998 100644 --- a/get-shit-done/workflows/pause-work.md +++ b/get-shit-done/workflows/pause-work.md @@ -13,7 +13,7 @@ Find current phase directory from most recently modified files: ```bash # Find most recent phase directory with work -ls -lt .planning/phases/*/PLAN.md 2>/dev/null | head -1 | grep -oP 'phases/\K[^/]+' +(ls -lt .planning/phases/*/PLAN.md 2>/dev/null || true) | head -1 | grep -oP 'phases/\K[^/]+' || true ``` If no active phase detected, ask user which phase they're pausing work on. @@ -36,7 +36,7 @@ Ask user for clarifications if needed via conversational questions. **Also inspect SUMMARY.md files for false completions:** ```bash # Check for placeholder content in existing summaries -grep -l "To be filled\|placeholder\|TBD" .planning/phases/*/*.md 2>/dev/null +grep -l "To be filled\|placeholder\|TBD" .planning/phases/*/*.md 2>/dev/null || true ``` Report any summaries with placeholder content as incomplete items. diff --git a/get-shit-done/workflows/plan-milestone-gaps.md b/get-shit-done/workflows/plan-milestone-gaps.md index 5913cb927..b3997fecf 100644 --- a/get-shit-done/workflows/plan-milestone-gaps.md +++ b/get-shit-done/workflows/plan-milestone-gaps.md @@ -12,7 +12,7 @@ Read all files referenced by the invoking prompt's execution_context before star ```bash # Find the most recent audit file -ls -t .planning/v*-MILESTONE-AUDIT.md 2>/dev/null | head -1 +(ls -t .planning/v*-MILESTONE-AUDIT.md 2>/dev/null || true) | head -1 ``` Parse YAML frontmatter to extract structured gaps: diff --git a/get-shit-done/workflows/plan-phase.md b/get-shit-done/workflows/plan-phase.md index f0ab019a6..dfc9eb1be 100644 --- a/get-shit-done/workflows/plan-phase.md +++ b/get-shit-done/workflows/plan-phase.md @@ -339,7 +339,7 @@ If `research_enabled` is false and `nyquist_validation_enabled` is true: warn "N In that case: **skip validation-strategy creation entirely**. Do **not** expect `RESEARCH.md` or `VALIDATION.md` for this run, and continue to Step 6. ```bash -grep -l "## Validation Architecture" "${PHASE_DIR}"/*-RESEARCH.md 2>/dev/null +grep -l "## Validation Architecture" "${PHASE_DIR}"/*-RESEARCH.md 2>/dev/null || true ``` **If found:** @@ -409,7 +409,7 @@ Otherwise use AskUserQuestion: ## 6. Check Existing Plans ```bash -ls "${PHASE_DIR}"/*-PLAN.md 2>/dev/null +ls "${PHASE_DIR}"/*-PLAN.md 2>/dev/null || true ``` **If exists AND `--reviews` flag:** Skip prompt — go straight to replanning (the purpose of `--reviews` is to replan with review feedback). diff --git a/get-shit-done/workflows/plant-seed.md b/get-shit-done/workflows/plant-seed.md index 918667cef..f49e9a888 100644 --- a/get-shit-done/workflows/plant-seed.md +++ b/get-shit-done/workflows/plant-seed.md @@ -86,7 +86,7 @@ Store relevant file paths as `$BREADCRUMBS`. ```bash # Find next seed number -EXISTING=$(ls .planning/seeds/SEED-*.md 2>/dev/null | wc -l) +EXISTING=$( (ls .planning/seeds/SEED-*.md 2>/dev/null || true) | wc -l ) NEXT=$((EXISTING + 1)) PADDED=$(printf "%03d" $NEXT) ``` diff --git a/get-shit-done/workflows/progress.md b/get-shit-done/workflows/progress.md index c8f1b38d3..6725493e1 100644 --- a/get-shit-done/workflows/progress.md +++ b/get-shit-done/workflows/progress.md @@ -85,7 +85,7 @@ Use this instead of manually reading/parsing ROADMAP.md. - Use `current_phase` and `next_phase` from `$ROADMAP` - Note `paused_at` if work was paused (from `$STATE`) - Count pending todos: use `init todos` or `list-todos` -- Check for active debug sessions: `ls .planning/debug/*.md 2>/dev/null | grep -v resolved | wc -l` +- Check for active debug sessions: `(ls .planning/debug/*.md 2>/dev/null || true) | grep -v resolved | wc -l` @@ -143,9 +143,9 @@ CONTEXT: [✓ if has_context | - if not] List files in the current phase directory: ```bash -ls -1 .planning/phases/[current-phase-dir]/*-PLAN.md 2>/dev/null | wc -l -ls -1 .planning/phases/[current-phase-dir]/*-SUMMARY.md 2>/dev/null | wc -l -ls -1 .planning/phases/[current-phase-dir]/*-UAT.md 2>/dev/null | wc -l +(ls -1 .planning/phases/[current-phase-dir]/*-PLAN.md 2>/dev/null || true) | wc -l +(ls -1 .planning/phases/[current-phase-dir]/*-SUMMARY.md 2>/dev/null || true) | wc -l +(ls -1 .planning/phases/[current-phase-dir]/*-UAT.md 2>/dev/null || true) | wc -l ``` State: "This phase has {X} plans, {Y} summaries." @@ -156,7 +156,7 @@ Check for UAT.md files with status "diagnosed" (has gaps needing fixes). ```bash # Check for diagnosed UAT with gaps or partial (incomplete) testing -grep -l "status: diagnosed\|status: partial" .planning/phases/[current-phase-dir]/*-UAT.md 2>/dev/null +grep -l "status: diagnosed\|status: partial" .planning/phases/[current-phase-dir]/*-UAT.md 2>/dev/null || true ``` Track: diff --git a/get-shit-done/workflows/research-phase.md b/get-shit-done/workflows/research-phase.md index e1fcee7d4..f77ec1bbc 100644 --- a/get-shit-done/workflows/research-phase.md +++ b/get-shit-done/workflows/research-phase.md @@ -26,7 +26,7 @@ If `found` is false: Error and exit. ## Step 2: Check Existing Research ```bash -ls .planning/phases/${PHASE}-*/RESEARCH.md 2>/dev/null +ls .planning/phases/${PHASE}-*/RESEARCH.md 2>/dev/null || true ``` If exists: Offer update/view/skip options. diff --git a/get-shit-done/workflows/resume-project.md b/get-shit-done/workflows/resume-project.md index 06dff4909..aff2fec6b 100644 --- a/get-shit-done/workflows/resume-project.md +++ b/get-shit-done/workflows/resume-project.md @@ -64,16 +64,17 @@ Look for incomplete work that needs attention: ```bash # Check for structured handoff (preferred — machine-readable) -cat .planning/HANDOFF.json 2>/dev/null +cat .planning/HANDOFF.json 2>/dev/null || true # Check for continue-here files (mid-plan resumption) -ls .planning/phases/*/.continue-here*.md 2>/dev/null +ls .planning/phases/*/.continue-here*.md 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}" [ ! -f "$summary" ] && echo "Incomplete: $plan" -done 2>/dev/null +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 @@ -215,7 +216,7 @@ What would you like to do? **Note:** When offering phase planning, check for CONTEXT.md existence first: ```bash -ls .planning/phases/XX-name/*-CONTEXT.md 2>/dev/null +ls .planning/phases/XX-name/*-CONTEXT.md 2>/dev/null || true ``` If missing, suggest discuss-phase before plan. If exists, offer plan directly. diff --git a/get-shit-done/workflows/transition.md b/get-shit-done/workflows/transition.md index 226b9d8ee..2dd4fed05 100644 --- a/get-shit-done/workflows/transition.md +++ b/get-shit-done/workflows/transition.md @@ -41,8 +41,8 @@ Mark current phase complete and advance to next. This is the natural point where Before transition, read project state: ```bash -cat .planning/STATE.md 2>/dev/null -cat .planning/PROJECT.md 2>/dev/null +cat .planning/STATE.md 2>/dev/null || true +cat .planning/PROJECT.md 2>/dev/null || true ``` Parse current position to verify we're transitioning the right phase. @@ -55,8 +55,8 @@ Note accumulated context that may need updating after transition. Check current phase has all plan summaries: ```bash -ls .planning/phases/XX-current/*-PLAN.md 2>/dev/null | sort -ls .planning/phases/XX-current/*-SUMMARY.md 2>/dev/null | sort +(ls .planning/phases/XX-current/*-PLAN.md 2>/dev/null || true) | sort +(ls .planning/phases/XX-current/*-SUMMARY.md 2>/dev/null || true) | sort ``` **Verification logic:** @@ -69,7 +69,7 @@ ls .planning/phases/XX-current/*-SUMMARY.md 2>/dev/null | sort ```bash -cat .planning/config.json 2>/dev/null +cat .planning/config.json 2>/dev/null || true ``` @@ -151,7 +151,7 @@ Wait for user decision. Check for lingering handoffs: ```bash -ls .planning/phases/XX-current/.continue-here*.md 2>/dev/null +ls .planning/phases/XX-current/.continue-here*.md 2>/dev/null || true ``` If found, delete them — phase is complete, handoffs are stale. @@ -429,7 +429,7 @@ Read ROADMAP.md to get the next phase's name and goal. **Check if next phase has CONTEXT.md:** ```bash -ls .planning/phases/*[X+1]*/*-CONTEXT.md 2>/dev/null +ls .planning/phases/*[X+1]*/*-CONTEXT.md 2>/dev/null || true ``` **If next phase exists:** diff --git a/get-shit-done/workflows/verify-phase.md b/get-shit-done/workflows/verify-phase.md index 082ac96dd..fa9ddf64e 100644 --- a/get-shit-done/workflows/verify-phase.md +++ b/get-shit-done/workflows/verify-phase.md @@ -37,8 +37,8 @@ Extract from init JSON: `phase_dir`, `phase_number`, `phase_name`, `has_plans`, Then load phase details and list plans/summaries: ```bash node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" roadmap get-phase "${phase_number}" -grep -E "^| ${phase_number}" .planning/REQUIREMENTS.md 2>/dev/null -ls "$phase_dir"/*-SUMMARY.md "$phase_dir"/*-PLAN.md 2>/dev/null +grep -E "^| ${phase_number}" .planning/REQUIREMENTS.md 2>/dev/null || true +ls "$phase_dir"/*-SUMMARY.md "$phase_dir"/*-PLAN.md 2>/dev/null || true ``` Extract **phase goal** from ROADMAP.md (the outcome to verify, not tasks) and **requirements** from REQUIREMENTS.md if it exists. @@ -171,7 +171,7 @@ Record status and evidence for each key link. If REQUIREMENTS.md exists: ```bash -grep -E "Phase ${PHASE_NUM}" .planning/REQUIREMENTS.md 2>/dev/null +grep -E "Phase ${PHASE_NUM}" .planning/REQUIREMENTS.md 2>/dev/null || true ``` For each requirement: parse description → identify supporting truths/artifacts → status: ✓ SATISFIED / ✗ BLOCKED / ? NEEDS HUMAN. diff --git a/get-shit-done/workflows/verify-work.md b/get-shit-done/workflows/verify-work.md index 7cade7f32..359be7ae1 100644 --- a/get-shit-done/workflows/verify-work.md +++ b/get-shit-done/workflows/verify-work.md @@ -35,7 +35,7 @@ Parse JSON for: `planner_model`, `checker_model`, `commit_docs`, `phase_found`, **First: Check for active UAT sessions** ```bash -find .planning/phases -name "*-UAT.md" -type f 2>/dev/null | head -5 +(find .planning/phases -name "*-UAT.md" -type f 2>/dev/null || true) | head -5 ``` **If active sessions exist AND no $ARGUMENTS provided:** @@ -84,7 +84,7 @@ Continue to `create_uat_file`. Use `phase_dir` from init (or run init if not already done). ```bash -ls "$phase_dir"/*-SUMMARY.md 2>/dev/null +ls "$phase_dir"/*-SUMMARY.md 2>/dev/null || true ``` Read each SUMMARY.md to extract testable deliverables. diff --git a/tests/core.test.cjs b/tests/core.test.cjs index 01df70fd3..c4a234a9b 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -1477,6 +1477,38 @@ describe('findProjectRoot', () => { assert.strictEqual(findProjectRoot(backendDir), backendDir); }); + + test('walks up from subdirectory when .git is at same level as .planning/ (single-repo)', () => { + // Common single-repo layout: .git and .planning are siblings at project root + fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true }); + + // User cwd is a subdirectory (e.g., src/) + const srcDir = path.join(projectRoot, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + // Should detect that parent has .planning/ and .git is at that same level + assert.strictEqual(findProjectRoot(srcDir), projectRoot); + }); + + test('walks up from deep subdirectory when .git is at same level as .planning/', () => { + // Single-repo: .git and .planning at root, cwd deep inside + fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true }); + + const deepDir = path.join(projectRoot, 'src', 'lib', 'utils'); + fs.mkdirSync(deepDir, { recursive: true }); + + assert.strictEqual(findProjectRoot(deepDir), projectRoot); + }); + + test('returns startDir when .planning exists at same level (cwd is project root)', () => { + // User is already at project root — no parent to walk up to + fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true }); + + assert.strictEqual(findProjectRoot(projectRoot), projectRoot); + }); }); // ─── reapStaleTempFiles ───────────────────────────────────────────────────── diff --git a/tests/windows-robustness.test.cjs b/tests/windows-robustness.test.cjs new file mode 100644 index 000000000..25305dc9c --- /dev/null +++ b/tests/windows-robustness.test.cjs @@ -0,0 +1,204 @@ +/** + * Windows Robustness Tests + * + * Validates that workflow files, hooks, and core functions handle + * Windows/cross-platform edge cases correctly: + * + * 1. Workflow shell robustness: informational commands guarded with || true + * 2. Glob loops guarded with [ -e "$var" ] || continue + * 3. Hook stdin timeout patterns present in all JS hooks + * 4. findProjectRoot detects .git at same level as .planning/ + * 5. @file: handoff present in all workflows that call init + * + * Regression tests for: https://github.com/gsd-build/get-shit-done/issues/1343 + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); + +const WORKFLOWS_DIR = path.join(__dirname, '..', 'get-shit-done', 'workflows'); +const HOOKS_DIR = path.join(__dirname, '..', 'hooks'); + +/** + * Extract bash code blocks from a markdown file. + * Returns array of { lineNumber, code } objects. + */ +function extractBashBlocks(content) { + const blocks = []; + const lines = content.split('\n'); + let inBlock = false; + let blockStart = 0; + let blockLines = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.trim().startsWith('```bash')) { + inBlock = true; + blockStart = i + 1; + blockLines = []; + } else if (inBlock && line.trim() === '```') { + inBlock = false; + blocks.push({ lineNumber: blockStart, code: blockLines.join('\n') }); + } else if (inBlock) { + blockLines.push(line); + } + } + return blocks; +} + +/** + * Check if a line is an informational command that can return non-zero on + * "no results" and should be guarded with || true. + * + * Matches: ls, grep, find, cat on optional files — commands at end of line + * with 2>/dev/null that are NOT already guarded. + */ +function findUnguardedInfoCommands(code) { + const issues = []; + const lines = code.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Skip comments, empty lines, and lines that are already guarded + if (!line || line.startsWith('#')) continue; + if (line.includes('|| true') || line.includes('|| echo') || line.includes('|| continue')) continue; + + // Lines ending with 2>/dev/null that use informational commands + if (line.endsWith('2>/dev/null')) { + // Check if this is an informational command (ls, grep, find, cat on optional files) + if (/^(ls|grep|find|cat)\s/.test(line) || + /\|\s*(ls|grep|find)\s/.test(line)) { + issues.push({ line: i + 1, content: line }); + } + } + } + return issues; +} + +// ─── Workflow Shell Robustness ──────────────────────────────────────────────── + +describe('workflow shell robustness', () => { + const workflowFiles = fs.readdirSync(WORKFLOWS_DIR) + .filter(f => f.endsWith('.md')); + + // Key workflow files that must have || true guards on informational commands + const criticalWorkflows = [ + 'resume-project.md', + 'progress.md', + 'transition.md', + 'verify-phase.md', + 'verify-work.md', + 'discuss-phase.md', + 'plan-phase.md', + 'execute-plan.md', + 'cleanup.md', + ]; + + for (const wf of criticalWorkflows) { + test(`${wf}: informational commands are guarded with || true`, () => { + const filePath = path.join(WORKFLOWS_DIR, wf); + if (!fs.existsSync(filePath)) return; // skip if workflow doesn't exist + const content = fs.readFileSync(filePath, 'utf-8'); + const blocks = extractBashBlocks(content); + const allIssues = []; + + for (const block of blocks) { + const issues = findUnguardedInfoCommands(block.code); + for (const issue of issues) { + allIssues.push(`Line ~${block.lineNumber + issue.line}: ${issue.content}`); + } + } + + assert.strictEqual( + allIssues.length, 0, + `${wf} has unguarded informational commands that may fail on Windows:\n ${allIssues.join('\n ')}` + ); + }); + } + + test('glob loops in resume-project.md have existence guard', () => { + const content = fs.readFileSync(path.join(WORKFLOWS_DIR, 'resume-project.md'), 'utf-8'); + const blocks = extractBashBlocks(content); + + for (const block of blocks) { + // Look for `for ... in .planning/` glob loops + const forLoopMatch = block.code.match(/for\s+\w+\s+in\s+\.planning\/[^;]+;\s*do/); + if (forLoopMatch) { + // The loop body should contain [ -e "$var" ] || continue + assert.ok( + block.code.includes('|| continue'), + `Glob loop at line ~${block.lineNumber} missing existence guard ([ -e "$var" ] || continue):\n${forLoopMatch[0]}` + ); + } + } + }); +}); + +// ─── Hook Stdin Timeout ────────────────────────────────────────────────────── + +describe('hook stdin timeout patterns', () => { + test('all JS hooks have stdin timeout guard', () => { + if (!fs.existsSync(HOOKS_DIR)) return; + + const hookFiles = fs.readdirSync(HOOKS_DIR) + .filter(f => f.endsWith('.js')); + + for (const hook of hookFiles) { + const content = fs.readFileSync(path.join(HOOKS_DIR, hook), 'utf-8'); + + // Hooks that read stdin must have a timeout + if (content.includes('process.stdin')) { + assert.ok( + content.includes('setTimeout') || content.includes('stdinTimeout'), + `${hook} reads stdin but lacks a timeout guard — will hang on Windows if stdin pipe doesn't close` + ); + } + } + }); + + test('no JS hooks use synchronous readFileSync on /dev/stdin', () => { + if (!fs.existsSync(HOOKS_DIR)) return; + + const hookFiles = fs.readdirSync(HOOKS_DIR) + .filter(f => f.endsWith('.js')); + + for (const hook of hookFiles) { + const content = fs.readFileSync(path.join(HOOKS_DIR, hook), 'utf-8'); + assert.ok( + !content.includes("readFileSync('/dev/stdin')") && + !content.includes('readFileSync("/dev/stdin")'), + `${hook} uses readFileSync('/dev/stdin') which hangs on Windows — use async process.stdin with timeout instead` + ); + } + }); +}); + +// ─── @file: Handoff ───────────────────────────────────────────────────────── + +describe('@file: handoff in workflows', () => { + test('all workflows calling gsd-tools init have @file: handler', () => { + const workflowFiles = fs.readdirSync(WORKFLOWS_DIR) + .filter(f => f.endsWith('.md')); + + const missing = []; + for (const wf of workflowFiles) { + const content = fs.readFileSync(path.join(WORKFLOWS_DIR, wf), 'utf-8'); + + // Check if this workflow calls gsd-tools.cjs init + if (/INIT=\$\(node.*gsd-tools.*\binit\b/.test(content)) { + // Must have @file: handler + if (!content.includes('@file:')) { + missing.push(wf); + } + } + } + + assert.strictEqual( + missing.length, 0, + `Workflows calling gsd-tools init without @file: handler (large output will be truncated):\n ${missing.join('\n ')}` + ); + }); +});