* feat(#3146): resolve gsd_run so workflows cannot reach a foreign gsd-tools The predecessor package get-shit-done-cc publishes a colliding gsd-tools bin whose phases.clear DELETES where this package's ARCHIVES, and both print success-shaped output against a gitignored .planning/ -- which is how #3129 cost a user 43 phase directories with no error and nothing recoverable from git. The launcher's PATH branch now resolves gsd_run, published only by this package and self-locating via its own symlink chain to the sibling shim, instead of the colliding gsd-tools. A foreign handler becomes unreachable from PATH, and when no gsd_run is reachable the resolver fails closed rather than falling back -- that fallback was the vulnerability. This is smaller than the branch it replaces, which matters: the preamble is inlined into 113 shipped files and agents/gsd-verifier.md sits 2 bytes under a red-line size cap. unset -f gsd_run leads the preamble so a re-source is idempotent. Without it, command -v finds the shell function, returns a bare name, and the resolver falls through to an exit 1 that kills a sourced caller's shell. Adds gsd-tools runtime-identity, a manual diagnostic reporting this runtime's package coordinates over the baked package-identity (#498) and readHostVersion, with a strict total classifier: only a JSON object with an exact packageName verifies, since JSON.parse admits 0/"str"/[]/null/true. An inlined identity assertion was built and reviewed first, then withdrawn -- it breaks five frozen size ceilings and no assertion fits in 2 bytes. Closes #3146 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3146): stop sync:launcher relocating a deliberate preamble placement Pre-existing defect, surfaced by this PR because sync is a no-op unless the snippet content actually changes. transformFile inserts the preamble into the first block that CALLS gsd_run, but gsd-core/workflows/explore.md deliberately places it in a bootstrap-only block that DEFINES gsd_run without calling it -- its own comment explains why: declining the research offer must not leave Step 5's commit call unbootstrapped. Stripping empties that block of calls, so the preamble migrated forward and broke the define-before-use invariant tests/explore-command.test.cjs pins. Reproduced on a pristine origin/next checkout with the base snippet and base file, so this was not introduced here. The insertion target now honours a block that already carried the preamble, falling back to the first calling block for files that have none yet. Adds a behavioral regression test over a two-block fixture. Also updates three runtime-launcher-parity tests that pinned the removed PATH fallback to gsd-tools. Their intent is preserved -- the PATH stub is renamed gsd_run so it is reachable by the new resolver, and the RUNTIME_DIR-wins test still asserts the stub is never invoked. Fixture shebangs move to an absolute /bin/sh, because the fixture PATH is deliberately restricted and #!/usr/bin/env sh could not resolve. Refs #3146 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3146): backfill changeset PR number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3146): document the FEATURES.md section-numbering practice The monotonically increasing section number in docs/FEATURES.md is the most frequent merge-conflict source in this repo, and it has TWO conflict cells, not one: the ### N. heading and the hand-maintained table of contents. Two PRs adding differently numbered features still collide on the TOC, so renumbering alone does not make a branch safe. This branch alone was renumbered 165 -> 166 -> 167 -> 168 across successive rebases. Adds a CONTRIBUTING section stating the practice: allocate the number last, never pre-emptively renumber, take max+1 after a rebase and update the TOC in the same commit, and never renumber someone else's section. Fork contributors are told explicitly they may leave the number to a maintainer at merge rather than chasing the counter. Agents are told to lease the allocation and to include the file in their published touched set. Records the durable fix as planned rather than pretending it exists: FEATURES.md should be generated from per-feature fragments the way CHANGELOG.md is generated from .changeset/, and the way tests/emitted-drift-acks/ works (#2914). Also renumbers this branch's own section to 168, leaving 167 to the PR already in flight. Refs #3146 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
13 KiB
<required_reading>
@/.claude/gsd-core/references/ui-brand.md
@/.claude/gsd-core/references/gate-prompts.md
</required_reading>
If response_language is set: All user-facing questions, prompts, and explanations in this workflow MUST be presented in {response_language}. Technical terms, code, file paths, and subagent prompts stay in English — only user-facing output is translated.
### GSD ► UNDO
--last N→ MODE=last, COUNT=N (integer, default 10 if N missing)--phase NN→ MODE=phase, TARGET_PHASE=NN (two-digit phase number)--plan NN-MM→ MODE=plan, TARGET_PLAN=NN-MM (phase-plan ID)
If no valid argument is provided, display usage and exit:
Usage: /gsd:undo --last N | --phase NN | --plan NN-MM
Modes:
--last N Show last N GSD commits for interactive selection
--phase NN Revert all commits for phase NN
--plan NN-MM Revert all commits for plan NN-MM
Examples:
/gsd:undo --last 5
/gsd:undo --phase 03
/gsd:undo --plan 03-02
MODE=last:
Run:
git log --oneline --no-merges -${COUNT}
Filter for GSD conventional commits matching type(scope): message pattern (e.g., feat(04-01):, docs(03):, fix(02-03):).
Display a numbered list of matching commits:
Recent GSD commits:
1. abc1234 feat(04-01): implement auth endpoint
2. def5678 docs(03-02): complete plan summary
3. ghi9012 fix(02-03): correct validation logic
Text mode (workflow.text_mode: true in config or --text flag): Set TEXT_MODE=true if --text is present in $ARGUMENTS OR text_mode from init JSON is true. When TEXT_MODE is active, replace every AskUserQuestion call with a plain-text numbered list and ask the user to type their choice number. This is required for non-Claude runtimes (OpenAI Codex, Gemini CLI, etc.) where AskUserQuestion is not available.
Use AskUserQuestion to ask:
- question: "Which commits to revert? Enter numbers (e.g., 1,3) or 'all'"
- header: "Select"
Parse the user's selection into COMMITS list.
MODE=phase:
Read .planning/.phase-manifest.json if it exists.
If the file exists and manifest.phases?.[TARGET_PHASE]?.commits is a non-empty array:
- Use
manifest.phases[TARGET_PHASE].commitsentries as COMMITS (each entry is a commit hash)
If the file does not exist, or manifest.phases?.[TARGET_PHASE] is missing:
- Display: "Manifest has no entry for phase ${TARGET_PHASE} (or file missing), falling back to git log search"
- Fallback: run git log and filter for the target phase scope:
git log --oneline --no-merges --all | grep -E "\(0*${TARGET_PHASE}(-[0-9]+)?\):" | head -50 - Use matching commits as COMMITS
MODE=plan:
Run:
git log --oneline --no-merges --all | grep -E "\(${TARGET_PLAN}\)" | head -50
Use matching commits as COMMITS.
Empty check:
If COMMITS is empty after gathering:
No commits found for ${MODE} ${TARGET}. Nothing to revert.
Exit cleanly.
**Applies when MODE=phase or MODE=plan.**Skip this step entirely for MODE=last.
MODE=phase:
Read .planning/ROADMAP.md inline.
Search for phases that list a dependency on the target phase. Look for patterns like:
- "Depends on: Phase ${TARGET_PHASE}"
- "Depends on: ${TARGET_PHASE}"
- "depends_on: [${TARGET_PHASE}]"
For each dependent phase N found:
- Check if
.planning/phases/${N}-*/directory exists - If directory exists, check for any PLAN.md or SUMMARY.md files inside it
If any downstream phase has started work, collect warnings:
⚠ Downstream dependency detected:
Phase ${N} depends on Phase ${TARGET_PHASE} and has started work.
MODE=plan:
Extract the phase number from TARGET_PLAN (the NN part of NN-MM). Extract the plan number (the MM part).
Look for later plans in the same phase directory (.planning/phases/${NN}-*/). For each later plan (plans with number > MM):
- Read the later plan's PLAN.md
- Check if its
<files>sections orconsumesfields reference outputs from the target plan
If any later plan references the target plan's outputs, collect warnings:
⚠ Intra-phase dependency detected:
Plan ${LATER_PLAN} in phase ${NN} references outputs from plan ${TARGET_PLAN}.
If any warnings exist (from either mode):
- Display all warnings
- Use AskUserQuestion with approve-revise-abort pattern:
- question: "Downstream work depends on the target being reverted. Proceed anyway?"
- header: "Confirm"
- options: Proceed | Abort
If user selects "Abort": exit with "Revert cancelled. No changes made."
Display the confirmation gate using approve-revise-abort pattern from gate-prompts.md.Show:
The following commits will be reverted (in reverse chronological order):
{hash} — {message}
{hash} — {message}
...
Total: {N} commit(s) to revert
Use AskUserQuestion:
- question: "Proceed with revert?"
- header: "Approve?"
- options: Approve | Abort
If "Abort": display "Revert cancelled. No changes made." and exit. If "Approve": ask for a reason:
AskUserQuestion(
header: "Reason",
question: "Brief reason for the revert (used in commit message):",
options: []
)
Store the response as REVERT_REASON. Continue to execute_revert.
**HARD CONSTRAINT: Use git revert --no-commit. NEVER use git reset (except for conflict cleanup as documented below).**Dirty-tree guard (run first, before any revert):
Run git status --porcelain. If the output is non-empty, display the dirty files and abort:
Working tree has uncommitted changes. Commit or stash them before running /gsd:undo.
Exit immediately — do not proceed to any revert operations.
Sort COMMITS in reverse chronological order (newest first). If commits came from git log (already newest-first), they are already in correct order.
For each commit hash in COMMITS:
git revert --no-commit ${HASH}
If any revert fails (merge conflict or error):
- Display the error message
- Run cleanup — handle both first-call and mid-sequence cases:
# Try git revert --abort first (works if this is the first failed revert) git revert --abort 2>/dev/null # If prior --no-commit reverts already staged cleanly before this failure, # revert --abort may be a no-op. Clean up staged and working tree changes: git reset HEAD 2>/dev/null git restore . 2>/dev/null - Display:
ERROR
Revert failed on commit ${HASH}. Likely cause: merge conflict with subsequent changes.
To fix: Resolve the conflict manually or revert commits individually. All pending reverts have been aborted — working tree is clean.
4. Exit with error.
After all reverts are staged successfully, create a single commit:
For MODE=phase:
```bash
git commit -m "revert(${TARGET_PHASE}): undo phase ${TARGET_PHASE} — ${REVERT_REASON}"
For MODE=plan:
git commit -m "revert(${TARGET_PLAN}): undo plan ${TARGET_PLAN} — ${REVERT_REASON}"
For MODE=last:
git commit -m "revert: undo ${N} selected commits — ${REVERT_REASON}"
### GSD ► UNDO COMPLETE ✓
Show summary:
✓ ${N} commit(s) reverted
✓ Single revert commit created: ${REVERT_HASH}
Show next steps:
---
## ▶ Next Up — [${PROJECT_CODE}] ${PROJECT_TITLE}
**Review state** — verify project is in expected state after revert
/clear then:
/gsd:progress
---
**Also available:**
- `/gsd:execute-phase ${PHASE}` — re-execute if needed
- `/gsd:undo --last 1` — undo the revert itself if something went wrong
---
<success_criteria>
- Arguments parsed correctly for all three modes
- --phase mode reads .planning/.phase-manifest.json using manifest.phases[TARGET_PHASE].commits
- --phase mode falls back to git log if manifest entry missing
- Dependency check warns when downstream phases have started (MODE=phase)
- Dependency check warns when later plans reference target plan outputs (MODE=plan)
- Dirty-tree guard aborts if working tree has uncommitted changes
- Confirmation gate shown before any revert execution
- Reverts use git revert --no-commit in reverse chronological order
- Single commit created after all reverts staged
- Error handling cleans up both first-call and mid-sequence conflict cases
- git reset --hard is NEVER used anywhere in this workflow </success_criteria>