diff --git a/agents/gsd-user-profiler.md b/agents/gsd-user-profiler.md index c6ac1cc58..cacfb3dbf 100644 --- a/agents/gsd-user-profiler.md +++ b/agents/gsd-user-profiler.md @@ -38,7 +38,7 @@ Key characteristics of the input: -@get-shit-done/references/user-profiling.md +@~/.claude/get-shit-done/references/user-profiling.md This is the detection heuristics rubric. Read it in full before analyzing any messages. It defines: - The 8 dimensions and their rating spectrums @@ -52,7 +52,7 @@ This is the detection heuristics rubric. Read it in full before analyzing any me -Read the user-profiling reference document at `get-shit-done/references/user-profiling.md` to load: +Read the user-profiling reference document at `~/.claude/get-shit-done/references/user-profiling.md` to load: - All 8 dimension definitions with rating spectrums - Signal patterns and detection heuristics per dimension - Confidence scoring thresholds (HIGH: 10+ signals across 2+ projects, MEDIUM: 5-9, LOW: <5, UNSCORED: 0) diff --git a/commands/gsd/cleanup.md b/commands/gsd/cleanup.md index c95b2af1b..874bdeab2 100644 --- a/commands/gsd/cleanup.md +++ b/commands/gsd/cleanup.md @@ -1,6 +1,11 @@ --- name: gsd:cleanup description: Archive accumulated phase directories from completed milestones +allowed-tools: + - Read + - Write + - Bash + - AskUserQuestion --- Archive phase directories from completed milestones into `.planning/milestones/v{X.Y}-phases/`. diff --git a/commands/gsd/discuss-phase.md b/commands/gsd/discuss-phase.md index 65dd0be3b..b710bf134 100644 --- a/commands/gsd/discuss-phase.md +++ b/commands/gsd/discuss-phase.md @@ -34,6 +34,10 @@ Extract implementation decisions that downstream agents need — researcher and @~/.claude/get-shit-done/templates/context.md + +**Copilot (VS Code):** Use `vscode_askquestions` wherever this workflow calls `AskUserQuestion`. They are equivalent — `vscode_askquestions` is the VS Code Copilot implementation of the same interactive question API. + + Phase number: $ARGUMENTS (required) diff --git a/commands/gsd/execute-phase.md b/commands/gsd/execute-phase.md index 478a097ec..ef2a54935 100644 --- a/commands/gsd/execute-phase.md +++ b/commands/gsd/execute-phase.md @@ -35,6 +35,10 @@ Context budget: ~15% orchestrator, 100% fresh per subagent. @~/.claude/get-shit-done/references/ui-brand.md + +**Copilot (VS Code):** Use `vscode_askquestions` wherever this workflow calls `AskUserQuestion`. They are equivalent — `vscode_askquestions` is the VS Code Copilot implementation of the same interactive question API. + + Phase: $ARGUMENTS diff --git a/commands/gsd/help.md b/commands/gsd/help.md index d4fedc277..212d24c1a 100644 --- a/commands/gsd/help.md +++ b/commands/gsd/help.md @@ -1,6 +1,8 @@ --- name: gsd:help description: Show available GSD commands and usage guide +allowed-tools: + - Read --- Display the complete GSD command reference. diff --git a/commands/gsd/join-discord.md b/commands/gsd/join-discord.md index 06c218a4e..4290e709f 100644 --- a/commands/gsd/join-discord.md +++ b/commands/gsd/join-discord.md @@ -1,6 +1,7 @@ --- name: gsd:join-discord description: Join the GSD Discord community +allowed-tools: [] --- diff --git a/commands/gsd/new-project.md b/commands/gsd/new-project.md index 508b08dd9..339f50f96 100644 --- a/commands/gsd/new-project.md +++ b/commands/gsd/new-project.md @@ -9,6 +9,10 @@ allowed-tools: - Task - AskUserQuestion --- + +**Copilot (VS Code):** Use `vscode_askquestions` wherever this workflow calls `AskUserQuestion`. They are equivalent — `vscode_askquestions` is the VS Code Copilot implementation of the same interactive question API. + + **Flags:** - `--auto` — Automatic mode. After config questions, runs research → requirements → roadmap without further interaction. Expects idea document via @ reference. diff --git a/commands/gsd/plan-phase.md b/commands/gsd/plan-phase.md index 9af3ce0e7..f37b305a4 100644 --- a/commands/gsd/plan-phase.md +++ b/commands/gsd/plan-phase.md @@ -10,6 +10,7 @@ allowed-tools: - Glob - Grep - Task + - AskUserQuestion - WebFetch - mcp__context7__* --- @@ -26,6 +27,10 @@ Create executable phase prompts (PLAN.md files) for a roadmap phase with integra @~/.claude/get-shit-done/references/ui-brand.md + +**Copilot (VS Code):** Use `vscode_askquestions` wherever this workflow calls `AskUserQuestion`. They are equivalent — `vscode_askquestions` is the VS Code Copilot implementation of the same interactive question API. Do not skip questioning steps because `AskUserQuestion` appears unavailable; use `vscode_askquestions` instead. + + Phase number: $ARGUMENTS (optional — auto-detects next unplanned phase if omitted) diff --git a/commands/gsd/reapply-patches.md b/commands/gsd/reapply-patches.md index 39bc93377..e9d0cfbd4 100644 --- a/commands/gsd/reapply-patches.md +++ b/commands/gsd/reapply-patches.md @@ -1,4 +1,5 @@ --- +name: gsd:reapply-patches description: Reapply local modifications after a GSD update allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion --- @@ -223,22 +224,13 @@ Each matching commit represents an intentional user modification. Use the commit **Never report `Skipped — no custom content`.** If a file is in the backup, it has custom content. -## Step 5: Update manifest - -After reapplying, regenerate the file manifest so future updates correctly detect these as user modifications: - -```bash -# The manifest will be regenerated on next /gsd:update -# For now, just note which files were modified -``` - -## Step 6: Cleanup option +## Step 5: Cleanup option Ask user: - "Keep patch backups for reference?" → preserve `gsd-local-patches/` - "Clean up patch backups?" → remove `gsd-local-patches/` directory -## Step 7: Report +## Step 6: Report ``` ## Patches Reapplied diff --git a/commands/gsd/review-backlog.md b/commands/gsd/review-backlog.md index 91de5dd83..7b34fec89 100644 --- a/commands/gsd/review-backlog.md +++ b/commands/gsd/review-backlog.md @@ -5,6 +5,7 @@ allowed-tools: - Read - Write - Bash + - AskUserQuestion --- diff --git a/commands/gsd/workstreams.md b/commands/gsd/workstreams.md index 7a6677e35..58c680fa2 100644 --- a/commands/gsd/workstreams.md +++ b/commands/gsd/workstreams.md @@ -1,5 +1,10 @@ --- +name: gsd:workstreams description: Manage parallel workstreams — list, create, switch, status, progress, complete, and resume +allowed-tools: + - Read + - Write + - Bash --- # /gsd:workstreams diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index 544413d25..48e340c1e 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -276,7 +276,7 @@ function cmdInitNewProject(cwd, raw) { '.ex', '.exs', // Elixir '.clj', // Clojure ]); - const skipDirs = new Set(['node_modules', '.git', '.planning', '.claude', '__pycache__', 'target', 'dist', 'build']); + const skipDirs = new Set(['node_modules', '.git', '.planning', '.claude', '.codex', '__pycache__', 'target', 'dist', 'build']); function findCodeFiles(dir, depth) { if (depth > 3) return false; let entries; @@ -956,9 +956,11 @@ function cmdInitManager(cwd, raw) { } catch { /* intentionally empty */ } // Compute recommended actions (execute > plan > discuss) + // Skip BACKLOG phases (999.x numbering) — they are parked ideas, not active work const recommendedActions = []; for (const phase of phases) { if (phase.disk_status === 'complete') continue; + if (/^999(?:\.|$)/.test(phase.number)) continue; if (phase.disk_status === 'planned' && phase.deps_satisfied) { recommendedActions.push({ diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index d6080375a..11f49b0ad 100644 --- a/get-shit-done/bin/lib/phase.cjs +++ b/get-shit-done/bin/lib/phase.cjs @@ -743,12 +743,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) { ); // Mark completed plan checkboxes (safety net for missed per-plan updates) + // Handles both plain IDs ("- [ ] 01-01-PLAN.md") and bold-wrapped IDs ("- [ ] **01-01**") for (const summaryFile of phaseInfo.summaries) { const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''); if (!planId) continue; const planEscaped = escapeRegex(planId); const planCheckboxPattern = new RegExp( - `(-\\s*\\[) (\\]\\s*${planEscaped})`, + `(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i' ); roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2'); diff --git a/get-shit-done/bin/lib/roadmap.cjs b/get-shit-done/bin/lib/roadmap.cjs index e817c53e3..b0cd2f357 100644 --- a/get-shit-done/bin/lib/roadmap.cjs +++ b/get-shit-done/bin/lib/roadmap.cjs @@ -300,13 +300,13 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) { roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`); } - // Mark completed plan checkboxes (e.g. "- [ ] 50-01-PLAN.md" or "- [ ] 50-01:") + // Mark completed plan checkboxes (e.g. "- [ ] 50-01-PLAN.md", "- [ ] 50-01:", or "- [ ] **50-01**") for (const summaryFile of phaseInfo.summaries) { const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''); if (!planId) continue; const planEscaped = escapeRegex(planId); const planCheckboxPattern = new RegExp( - `(-\\s*\\[) (\\]\\s*${planEscaped})`, + `(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i' ); roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2'); @@ -314,7 +314,6 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) { fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); }); - output({ updated: true, phase: phaseNum, diff --git a/get-shit-done/workflows/diagnose-issues.md b/get-shit-done/workflows/diagnose-issues.md index 47f3e09d6..99cc12b86 100644 --- a/get-shit-done/workflows/diagnose-issues.md +++ b/get-shit-done/workflows/diagnose-issues.md @@ -88,6 +88,7 @@ This runs in parallel - all gaps investigated simultaneously. ```bash AGENT_SKILLS_DEBUGGER=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" agent-skills gsd-debugger 2>/dev/null) +EXPECTED_BASE=$(git rev-parse HEAD) ``` **Spawn debug agents in parallel:** @@ -96,7 +97,7 @@ For each gap, fill the debug-subagent-prompt template and spawn: ``` Task( - prompt=filled_debug_subagent_prompt + "\n\n\n- {phase_dir}/{phase_num}-UAT.md\n- .planning/STATE.md\n\n${AGENT_SKILLS_DEBUGGER}", + prompt=filled_debug_subagent_prompt + "\n\n\nFIRST ACTION: run git merge-base HEAD {EXPECTED_BASE} — if result differs from {EXPECTED_BASE}, run git reset --soft {EXPECTED_BASE} to correct the branch base (fixes Windows EnterWorktree creating branches from main).\n\n\n\n- {phase_dir}/{phase_num}-UAT.md\n- .planning/STATE.md\n\n${AGENT_SKILLS_DEBUGGER}", subagent_type="gsd-debugger", ${USE_WORKTREES !== "false" ? 'isolation="worktree",' : ''} description="Debug: {truth_short}" diff --git a/get-shit-done/workflows/execute-phase.md b/get-shit-done/workflows/execute-phase.md index 5077b9b0e..d3a2690ba 100644 --- a/get-shit-done/workflows/execute-phase.md +++ b/get-shit-done/workflows/execute-phase.md @@ -244,6 +244,11 @@ Execute each selected wave in sequence. Within a wave: parallel if `PARALLELIZAT **Worktree mode** (`USE_WORKTREES` is not `false`): + Before spawning, capture the current HEAD: + ```bash + EXPECTED_BASE=$(git rev-parse HEAD) + ``` + ``` Task( subagent_type="gsd-executor", @@ -256,6 +261,29 @@ Execute each selected wave in sequence. Within a wave: parallel if `PARALLELIZAT Commit each task atomically. Create SUMMARY.md. Update STATE.md and ROADMAP.md. + + FIRST ACTION before any other work: verify this worktree's branch is based on the correct commit. + + Run: + ```bash + ACTUAL_BASE=$(git merge-base HEAD {EXPECTED_BASE}) + CURRENT_HEAD=$(git rev-parse HEAD) + ``` + + If `ACTUAL_BASE` != `{EXPECTED_BASE}` (i.e. the worktree branch was created from an older + base such as `main` instead of the feature branch HEAD), rebase onto the correct base: + ```bash + git rebase --onto {EXPECTED_BASE} $(git rev-parse --abbrev-ref HEAD~1 2>/dev/null || git rev-parse HEAD^) HEAD 2>/dev/null || true + # If rebase fails or is a no-op, reset the branch to start from the correct base: + git reset --soft {EXPECTED_BASE} + ``` + + If `ACTUAL_BASE` == `{EXPECTED_BASE}`: the branch base is correct, proceed immediately. + + This check fixes a known issue on Windows where `EnterWorktree` creates branches from + `main` instead of the current feature branch HEAD. + + You are running as a PARALLEL executor agent. Use --no-verify on all git commits to avoid pre-commit hook contention with other agents. The diff --git a/get-shit-done/workflows/execute-plan.md b/get-shit-done/workflows/execute-plan.md index 920e92ab9..345e64073 100644 --- a/get-shit-done/workflows/execute-plan.md +++ b/get-shit-done/workflows/execute-plan.md @@ -72,7 +72,7 @@ grep -n "type=\"checkpoint" .planning/phases/XX-name/{phase}-{plan}-PLAN.md | Verify-only | B (segmented) | Segments between checkpoints. After none/human-verify → SUBAGENT. After decision/human-action → MAIN | | Decision | C (main) | Execute entirely in main context | -**Pattern A:** init_agent_tracking → spawn Task(subagent_type="gsd-executor", model=executor_model) with prompt: execute plan at [path], autonomous, all tasks + SUMMARY + commit, follow deviation/auth rules, report: plan name, tasks, SUMMARY path, commit hash → track agent_id → wait → update tracking → report. **Include `isolation="worktree"` only if `workflow.use_worktrees` is not `false`** (read via `config-get workflow.use_worktrees`). +**Pattern A:** init_agent_tracking → capture `EXPECTED_BASE=$(git rev-parse HEAD)` → spawn Task(subagent_type="gsd-executor", model=executor_model) with prompt: execute plan at [path], autonomous, all tasks + SUMMARY + commit, follow deviation/auth rules, report: plan name, tasks, SUMMARY path, commit hash → track agent_id → wait → update tracking → report. **Include `isolation="worktree"` only if `workflow.use_worktrees` is not `false`** (read via `config-get workflow.use_worktrees`). **When using `isolation="worktree"`, include a `` block in the prompt** instructing the executor to run `git merge-base HEAD {EXPECTED_BASE}` and, if the result differs from `{EXPECTED_BASE}`, reset the branch base with `git reset --soft {EXPECTED_BASE}` before starting work. This corrects a known issue on Windows where `EnterWorktree` creates branches from `main` instead of the feature branch HEAD. **Pattern B:** Execute segment-by-segment. Autonomous segments: spawn subagent for assigned tasks only (no SUMMARY/commit). Checkpoints: main context. After all segments: aggregate, create SUMMARY, commit. See segment_execution. diff --git a/get-shit-done/workflows/new-project.md b/get-shit-done/workflows/new-project.md index a5f50f8b9..d3861db9f 100644 --- a/get-shit-done/workflows/new-project.md +++ b/get-shit-done/workflows/new-project.md @@ -66,6 +66,29 @@ AGENT_SKILLS_ROADMAPPER=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" a Parse JSON for: `researcher_model`, `synthesizer_model`, `roadmapper_model`, `commit_docs`, `project_exists`, `has_codebase_map`, `planning_exists`, `has_existing_code`, `has_package_file`, `is_brownfield`, `needs_codebase_map`, `has_git`, `project_path`. +**Detect runtime and set instruction file name:** + +Derive `RUNTIME` from the invoking prompt's `execution_context` path: +- Path contains `/.codex/` → `RUNTIME=codex` +- Path contains `/.gemini/` → `RUNTIME=gemini` +- Path contains `/.config/opencode/` or `/.opencode/` → `RUNTIME=opencode` +- Otherwise → `RUNTIME=claude` + +If `execution_context` path is not available, fall back to env vars: +```bash +if [ -n "$CODEX_HOME" ]; then RUNTIME="codex" +elif [ -n "$GEMINI_CONFIG_DIR" ]; then RUNTIME="gemini" +elif [ -n "$OPENCODE_CONFIG_DIR" ] || [ -n "$OPENCODE_CONFIG" ]; then RUNTIME="opencode" +else RUNTIME="claude"; fi +``` + +Set the instruction file variable: +```bash +if [ "$RUNTIME" = "codex" ]; then INSTRUCTION_FILE="AGENTS.md"; else INSTRUCTION_FILE="CLAUDE.md"; fi +``` + +All subsequent references to the project instruction file use `$INSTRUCTION_FILE`. + **If `project_exists` is true:** Error — project already initialized. Use `/gsd:progress`. **If `has_git` is false:** Initialize git: @@ -1106,18 +1129,18 @@ Use AskUserQuestion: **If "Review full file":** Display raw `cat .planning/ROADMAP.md`, then re-ask. -**Generate or refresh project CLAUDE.md before final commit:** +**Generate or refresh project instruction file before final commit:** ```bash -node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" generate-claude-md +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" generate-claude-md --output "$INSTRUCTION_FILE" ``` -This ensures new projects get the default GSD workflow-enforcement guidance and current project context in `CLAUDE.md`. +This ensures new projects get the default GSD workflow-enforcement guidance and current project context in `$INSTRUCTION_FILE`. **Commit roadmap (after approval or auto mode):** ```bash -node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs: create roadmap ([N] phases)" --files .planning/ROADMAP.md .planning/STATE.md .planning/REQUIREMENTS.md CLAUDE.md +node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" commit "docs: create roadmap ([N] phases)" --files .planning/ROADMAP.md .planning/STATE.md .planning/REQUIREMENTS.md "$INSTRUCTION_FILE" ``` ## 9. Done @@ -1138,7 +1161,7 @@ Present completion summary: | Research | `.planning/research/` | | Requirements | `.planning/REQUIREMENTS.md` | | Roadmap | `.planning/ROADMAP.md` | -| Project guide | `CLAUDE.md` | +| Project guide | `$INSTRUCTION_FILE` | **[N] phases** | **[X] requirements** | Ready to build ✓ ``` @@ -1220,7 +1243,7 @@ PHASE1_HAS_UI=$(echo "$PHASE1_SECTION" | grep -qi "UI hint.*yes" && echo "true" - `.planning/REQUIREMENTS.md` - `.planning/ROADMAP.md` - `.planning/STATE.md` -- `CLAUDE.md` +- `$INSTRUCTION_FILE` (`AGENTS.md` for Codex, `CLAUDE.md` for all other runtimes) @@ -1242,7 +1265,7 @@ PHASE1_HAS_UI=$(echo "$PHASE1_SECTION" | grep -qi "UI hint.*yes" && echo "true" - [ ] ROADMAP.md created with phases, requirement mappings, success criteria - [ ] STATE.md initialized - [ ] REQUIREMENTS.md traceability updated -- [ ] CLAUDE.md generated with GSD workflow guidance +- [ ] `$INSTRUCTION_FILE` generated with GSD workflow guidance (AGENTS.md for Codex, CLAUDE.md otherwise) - [ ] User knows next step is `/gsd:discuss-phase 1` **Atomic commits:** Each phase commits its artifacts immediately. If context is lost, artifacts persist. diff --git a/get-shit-done/workflows/quick.md b/get-shit-done/workflows/quick.md index 0f3d8a11f..e78eaae11 100644 --- a/get-shit-done/workflows/quick.md +++ b/get-shit-done/workflows/quick.md @@ -550,6 +550,11 @@ Offer: 1) Force proceed, 2) Abort **Step 6: Spawn executor** +Capture current HEAD before spawning (used for worktree branch check): +```bash +EXPECTED_BASE=$(git rev-parse HEAD) +``` + Spawn gsd-executor with plan reference: ``` @@ -557,6 +562,15 @@ Task( prompt=" Execute quick task ${quick_id}. +${USE_WORKTREES !== "false" ? ` + +FIRST ACTION before any other work: verify this worktree branch is based on the correct commit. +Run: git merge-base HEAD ${EXPECTED_BASE} +If the result differs from ${EXPECTED_BASE}, run: git reset --soft ${EXPECTED_BASE} +This corrects a known issue on Windows where EnterWorktree creates branches from main instead of the feature branch HEAD. + +` : ''} + - ${QUICK_DIR}/${quick_id}-PLAN.md (Plan) - .planning/STATE.md (Project state) diff --git a/get-shit-done/workflows/settings.md b/get-shit-done/workflows/settings.md index 17c1bd593..d463b1130 100644 --- a/get-shit-done/workflows/settings.md +++ b/get-shit-done/workflows/settings.md @@ -34,6 +34,7 @@ Parse current values (default to `true` if not present): - `workflow.ui_safety_gate` — prompt to run /gsd:ui-phase before planning frontend phases (default: true if absent) - `model_profile` — which model each agent uses (default: `balanced`) - `git.branching_strategy` — branching approach (default: `"none"`) +- `workflow.use_worktrees` — whether parallel executor agents run in worktree isolation (default: `true`) @@ -153,6 +154,15 @@ AskUserQuestion([ { label: "No (Recommended)", description: "Run smart discuss before each phase — surfaces gray areas and captures decisions." }, { label: "Yes", description: "Skip discuss in /gsd:autonomous — chain directly to plan. Best for backend/pipeline work where phase descriptions are the spec." } ] + }, + { + question: "Use git worktrees for parallel agent isolation?", + header: "Worktrees", + multiSelect: false, + options: [ + { label: "Yes (Recommended)", description: "Each parallel executor runs in its own worktree branch — no conflicts between agents." }, + { label: "No", description: "Disable worktree isolation. Use on platforms where EnterWorktree is broken (e.g. Windows with feature branches). Agents run sequentially on the main working tree." } + ] } ]) ``` @@ -176,7 +186,8 @@ Merge new settings into existing config.json: "text_mode": true/false, "research_before_questions": true/false, "discuss_mode": "discuss" | "assumptions", - "skip_discuss": true/false + "skip_discuss": true/false, + "use_worktrees": true/false }, "git": { "branching_strategy": "none" | "phase" | "milestone", diff --git a/hooks/gsd-context-monitor.js b/hooks/gsd-context-monitor.js index ae1bbf9a3..505141072 100644 --- a/hooks/gsd-context-monitor.js +++ b/hooks/gsd-context-monitor.js @@ -45,6 +45,13 @@ process.stdin.on('end', () => { process.exit(0); } + // Reject session IDs that contain path traversal sequences or path separators. + // session_id is used to construct file paths in /tmp — an unsanitized value + // could escape the temp directory and read or write arbitrary files. + if (/[/\\]|\.\./.test(sessionId)) { + process.exit(0); + } + // Check if context warnings are disabled via config const cwd = data.cwd || process.cwd(); const configPath = path.join(cwd, '.planning', 'config.json'); diff --git a/hooks/gsd-statusline.js b/hooks/gsd-statusline.js index 32742693e..e745350c8 100755 --- a/hooks/gsd-statusline.js +++ b/hooks/gsd-statusline.js @@ -35,7 +35,10 @@ process.stdin.on('end', () => { // Write context metrics to bridge file for the context-monitor PostToolUse hook. // The monitor reads this file to inject agent-facing warnings when context is low. - if (session) { + // Reject session IDs with path separators or traversal sequences to prevent + // a malicious session_id from writing files outside the temp directory. + const sessionSafe = session && !/[/\\]|\.\./.test(session); + if (sessionSafe) { try { const bridgePath = path.join(os.tmpdir(), `claude-ctx-${session}.json`); const bridgeData = JSON.stringify({ diff --git a/tests/claude-md.test.cjs b/tests/claude-md.test.cjs index 8b204f434..7e4bb6d1e 100644 --- a/tests/claude-md.test.cjs +++ b/tests/claude-md.test.cjs @@ -65,18 +65,20 @@ describe('new-project workflow includes CLAUDE.md generation', () => { const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'new-project.md'); const commandsPath = path.join(__dirname, '..', 'docs', 'COMMANDS.md'); - test('new-project workflow generates CLAUDE.md before final commit', () => { + test('new-project workflow generates instruction file before final commit', () => { const content = fs.readFileSync(workflowPath, 'utf-8'); assert.ok(content.includes('generate-claude-md')); - assert.ok(content.includes('--files .planning/ROADMAP.md .planning/STATE.md .planning/REQUIREMENTS.md CLAUDE.md')); + // Codex fix: workflow now uses $INSTRUCTION_FILE (AGENTS.md for Codex, CLAUDE.md otherwise) + assert.ok(content.includes('--files .planning/ROADMAP.md .planning/STATE.md .planning/REQUIREMENTS.md "$INSTRUCTION_FILE"')); }); - test('new-project artifacts mention CLAUDE.md', () => { + test('new-project artifacts reference instruction file variable', () => { const workflowContent = fs.readFileSync(workflowPath, 'utf-8'); const commandsContent = fs.readFileSync(commandsPath, 'utf-8'); - assert.ok(workflowContent.includes('| Project guide | `CLAUDE.md`')); - assert.ok(workflowContent.includes('- `CLAUDE.md`')); + // Codex fix: hardcoded CLAUDE.md replaced with $INSTRUCTION_FILE variable + assert.ok(workflowContent.includes('| Project guide | `$INSTRUCTION_FILE`')); + assert.ok(workflowContent.includes('- `$INSTRUCTION_FILE`')); assert.ok(commandsContent.includes('`CLAUDE.md`')); }); }); diff --git a/tests/init-manager.test.cjs b/tests/init-manager.test.cjs index ebdb508e7..02a86eceb 100644 --- a/tests/init-manager.test.cjs +++ b/tests/init-manager.test.cjs @@ -479,4 +479,31 @@ describe('init manager', () => { assert.strictEqual(output.manager_flags.plan, '--valid-flag', 'valid flag should pass through'); assert.strictEqual(output.manager_flags.execute, '', 'command substitution should be sanitized'); }); + + test('does not recommend BACKLOG phases (999.x) as next actions', () => { + writeState(tmpDir); + // Regular phase (planned, deps met) plus a backlog phase (999.1) also planned + writeRoadmap(tmpDir, [ + { number: '1', name: 'Foundation' }, + { number: '999.1', name: 'Nice to have feature (BACKLOG)' }, + ]); + // Phase 1: planned (has plan, no summary) + scaffoldPhase(tmpDir, 1, { plans: 1 }); + // Phase 999.1: planned (has plan, no summary) + const backlogDir = path.join(tmpDir, '.planning', 'phases', '999.1-backlog'); + fs.mkdirSync(backlogDir, { recursive: true }); + fs.writeFileSync(path.join(backlogDir, '999.1-01-PLAN.md'), '# Backlog Plan'); + + const result = runGsdTools('init manager', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + const recommended = output.recommended_actions || []; + const backlogRecs = recommended.filter(r => /^999/.test(r.phase)); + assert.strictEqual(backlogRecs.length, 0, 'no 999.x phases should appear in recommended_actions'); + + // Phase 1 (non-backlog) should still be recommended + const activeRecs = recommended.filter(r => r.phase === '1'); + assert.strictEqual(activeRecs.length, 1, 'phase 1 should still be recommended'); + }); }); diff --git a/tests/init.test.cjs b/tests/init.test.cjs index 8550728be..c625091f2 100644 --- a/tests/init.test.cjs +++ b/tests/init.test.cjs @@ -33,6 +33,40 @@ describe('init commands', () => { assert.strictEqual(output.config_path, '.planning/config.json'); }); + test('init execute-phase respects model_overrides for executor_model', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ + model_profile: 'balanced', + model_overrides: { 'gsd-executor': 'openai/o4-mini' }, + })); + + const result = runGsdTools('init execute-phase 1 --raw', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.executor_model, 'openai/o4-mini', + 'model_overrides["gsd-executor"] must take precedence over profile'); + }); + + test('init execute-phase respects model_overrides when resolve_model_ids is omit', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ + resolve_model_ids: 'omit', + model_overrides: { 'gsd-executor': 'openai/o4-mini' }, + })); + + const result = runGsdTools('init execute-phase 1 --raw', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.executor_model, 'openai/o4-mini', + 'model_overrides must take precedence even when resolve_model_ids is omit'); + }); + test('init plan-phase returns file paths', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index c45ddb54d..6ba019389 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -1743,6 +1743,44 @@ Plans: assert.ok(!roadmap.includes('[ ] 01-01-PLAN.md'), 'plan 01-01 should not remain unchecked'); assert.ok(!roadmap.includes('[ ] 01-02-PLAN.md'), 'plan 01-02 should not remain unchecked'); }); + + test('marks bold-wrapped plan-level checkboxes on phase complete', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap + +- [ ] Phase 1: Foundation + +### Phase 1: Foundation +**Goal:** Setup +**Plans:** 2 plans + +Plans: +- [ ] **01-01**: Schema migration +- [ ] **01-02**: Auth setup +` + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-02\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` + ); + + const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(p1, { recursive: true }); + fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); + fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary'); + fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan'); + fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary'); + + const result = runGsdTools('phase complete 1', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); + assert.ok(roadmap.includes('[x] **01-01**'), 'bold plan 01-01 checkbox should be checked'); + assert.ok(roadmap.includes('[x] **01-02**'), 'bold plan 01-02 checkbox should be checked'); + assert.ok(!roadmap.includes('[ ] **01-01**'), 'bold plan 01-01 should not remain unchecked'); + assert.ok(!roadmap.includes('[ ] **01-02**'), 'bold plan 01-02 should not remain unchecked'); + }); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/tests/security.test.cjs b/tests/security.test.cjs index f6a12d38b..a4294f2b8 100644 --- a/tests/security.test.cjs +++ b/tests/security.test.cjs @@ -8,6 +8,7 @@ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const path = require('path'); const os = require('os'); +const fs = require('fs'); const { validatePath, @@ -414,3 +415,93 @@ describe('validateFieldName', () => { assert.ok(!validateFieldName('-field').valid); }); }); + +// ─── Hook session_id path traversal (#1533) ──────────────────────────────── +// Verify that gsd-context-monitor and gsd-statusline reject session_id values +// containing path traversal sequences before constructing temp file paths. + +const { execFileSync } = require('child_process'); + +function runHook(hookPath, inputJson) { + try { + const result = execFileSync(process.execPath, [hookPath], { + input: JSON.stringify(inputJson), + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 3000, + }); + return { exitCode: 0, stdout: result }; + } catch (err) { + return { exitCode: err.status || 1, stdout: err.stdout || '', stderr: err.stderr || '' }; + } +} + +describe('gsd-context-monitor session_id path traversal', () => { + const monitorPath = path.join(__dirname, '..', 'hooks', 'gsd-context-monitor.js'); + const tmpDir = os.tmpdir(); + + test('exits silently for session_id with ../ traversal', () => { + const maliciousId = '../../../etc/passwd'; + const result = runHook(monitorPath, { session_id: maliciousId }); + assert.strictEqual(result.exitCode, 0, 'hook should exit 0 for malicious session_id'); + assert.strictEqual(result.stdout.trim(), '', 'hook should produce no output for malicious session_id'); + const escapedPath = path.join(tmpDir, 'claude-ctx-' + maliciousId + '.json'); + assert.ok(!fs.existsSync(escapedPath), 'traversal file must not be created'); + }); + + test('exits silently for session_id with / separator', () => { + const maliciousId = 'foo/bar'; + const result = runHook(monitorPath, { session_id: maliciousId }); + assert.strictEqual(result.exitCode, 0); + assert.strictEqual(result.stdout.trim(), ''); + }); + + test('exits silently for session_id with backslash', () => { + const maliciousId = 'foo\\bar'; + const result = runHook(monitorPath, { session_id: maliciousId }); + assert.strictEqual(result.exitCode, 0); + assert.strictEqual(result.stdout.trim(), ''); + }); +}); + +describe('gsd-statusline session_id path traversal', () => { + const statuslinePath = path.join(__dirname, '..', 'hooks', 'gsd-statusline.js'); + const tmpDir = os.tmpdir(); + + const baseInput = { + model: { display_name: 'Claude' }, + context_window: { remaining_percentage: 80 }, + workspace: { current_dir: os.tmpdir() }, + }; + + test('does not write bridge file for session_id with ../ traversal', () => { + const maliciousId = '../../../etc/gsd-test'; + const bridgePath = path.join(tmpDir, 'claude-ctx-' + maliciousId + '.json'); + try { fs.unlinkSync(bridgePath); } catch { /* intentionally empty */ } + + runHook(statuslinePath, { ...baseInput, session_id: maliciousId }); + + assert.ok(!fs.existsSync(bridgePath), 'bridge file must not be written for traversal session_id'); + }); + + test('does not write bridge file for session_id with forward slash', () => { + const maliciousId = 'sub/path'; + const bridgePath = path.join(tmpDir, 'claude-ctx-' + maliciousId + '.json'); + try { fs.unlinkSync(bridgePath); } catch { /* intentionally empty */ } + + runHook(statuslinePath, { ...baseInput, session_id: maliciousId }); + + assert.ok(!fs.existsSync(bridgePath), 'bridge file must not be written for session_id with /'); + }); + + test('writes bridge file for safe session_id', () => { + const safeId = 'abc123-safe-session'; + const bridgePath = path.join(tmpDir, 'claude-ctx-' + safeId + '.json'); + try { fs.unlinkSync(bridgePath); } catch { /* intentionally empty */ } + + runHook(statuslinePath, { ...baseInput, session_id: safeId }); + + assert.ok(fs.existsSync(bridgePath), 'bridge file must be written for safe session_id'); + try { fs.unlinkSync(bridgePath); } catch { /* intentionally empty */ } + }); +});