Files
msd-core/gsd-core/workflows/spec-phase.md
Tom Boucher 63abcface9 feat(#3146): resolve gsd_run so workflows cannot reach a foreign gsd-tools (#3831)
* 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>
2026-08-24 20:57:16 -04:00

35 KiB
Raw Blame History

Clarify WHAT a phase delivers through a Socratic interview loop with quantitative ambiguity scoring. Produces a SPEC.md with falsifiable requirements that discuss-phase treats as locked decisions.

This workflow handles "what" and "why" — discuss-phase handles "how".

<ambiguity_model> Score each dimension 0.0 (completely unclear) to 1.0 (crystal clear):

Dimension Weight Minimum What it measures
Goal Clarity 35% 0.75 Is the outcome specific and measurable?
Boundary Clarity 25% 0.70 What's in scope vs out of scope?
Constraint Clarity 20% 0.65 Performance, compatibility, data requirements?
Acceptance Criteria 20% 0.70 How do we know it's done?

Ambiguity score = 1.0 − (0.35×goal + 0.25×boundary + 0.20×constraint + 0.20×acceptance)

Gate: ambiguity ≤ 0.20 AND all dimensions ≥ their minimums → ready to write SPEC.md.

A score of 0.20 means 80% weighted clarity — enough precision that the planner won't silently make wrong assumptions. </ambiguity_model>

<interview_perspectives> Rotate through these perspectives — each naturally surfaces different blindspots:

Researcher (rounds 1–2): Ground the discussion in current reality.

  • "What exists in the codebase today related to this phase?"
  • "What's the delta between today and the target state?"
  • "What triggers this work — what's broken or missing?"

Simplifier (round 2): Surface minimum viable scope.

  • "What's the simplest version that solves the core problem?"
  • "If you had to cut 50%, what's the irreducible core?"
  • "What would make this phase a success even without the nice-to-haves?"

Boundary Keeper (round 3): Lock the perimeter.

  • "What explicitly will NOT be done in this phase?"
  • "What adjacent problems is it tempting to solve but shouldn't?"
  • "What does 'done' look like — what's the final deliverable?"

Failure Analyst (round 4): Find the edge cases that invalidate requirements.

  • "What's the worst thing that could go wrong if we get the requirements wrong?"
  • "What does a broken version of this look like?"
  • "What would cause a verifier to reject the output?"

Seed Closer (rounds 5–6): Lock remaining undecided territory.

  • "We have [dimension] at [score] — what would make it completely clear?"
  • "The remaining ambiguity is in [area] — can we make a decision now?"
  • "Is there anything you'd regret not specifying before planning starts?" </interview_perspectives>

Step 1: Initialize

_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 unset -f gsd_run; _G="$(command -v gsd_run)"; then GSD_TOOLS="$_G"; 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_run 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 init phase-op "${PHASE}")
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi

Parse JSON for: phase_found, phase_dir, phase_number, phase_name, phase_slug, padded_phase, state_path, requirements_path, roadmap_path, planning_path, response_language, commit_docs.

If response_language is set: All user-facing text in this workflow MUST be in {response_language}. Technical terms, code, and file paths stay in English.

If phase_found is false:

Phase [X] not found in roadmap.
Use /gsd:progress to see available phases.

Exit.

Check for existing SPEC.md:

ls ${phase_dir}/*-SPEC.md 2>/dev/null | grep -v AI-SPEC | head -1 || true

If SPEC.md already exists:

If --auto: Auto-select "Update it". Log: [auto] SPEC.md exists — updating.

Otherwise: Use AskUserQuestion:

  • header: "Spec"
  • question: "Phase [X] already has a SPEC.md. What do you want to do?"
  • options:
    • "Update it" — Revise and re-score
    • "View it" — Show current spec
    • "Skip" — Exit (use existing spec as-is)

If "View": Display SPEC.md, then offer Update/Skip. If "Skip": Exit with message: "Existing SPEC.md unchanged. Run /gsd:discuss-phase [X] to continue." If "Update": Load existing SPEC.md, continue to Step 3.

Step 2: Scout Codebase

Read these files before any questions:

  • {requirements_path} — Project requirements
  • {state_path} — Decisions already made, current phase, blockers
  • ROADMAP.md phase entry — Phase description, goals, canonical refs

Grep the codebase for code/files relevant to this phase goal. Look for:

  • Existing implementations of similar functionality
  • Integration points where new code will connect
  • Test coverage gaps relevant to the phase
  • Prior phase artifacts (SUMMARY.md, VERIFICATION.md) that inform current state

Synthesize current state — the grounded baseline for the interview:

  • What exists today related to this phase
  • The gap between current state and the phase goal
  • The primary deliverable: what file/behavior/capability does NOT exist yet?

Confirm your current state synthesis internally. Do not present it to the user yet — you'll use it to ask precise, grounded questions.

Step 3: First Ambiguity Assessment

Before questioning begins, score the phase's current ambiguity based only on what ROADMAP.md and REQUIREMENTS.md say:

Goal Clarity:       [score 0.0–1.0]
Boundary Clarity:   [score 0.0–1.0]
Constraint Clarity: [score 0.0–1.0]
Acceptance Criteria:[score 0.0–1.0]

Ambiguity: [score] ([calculate])

If --auto and initial ambiguity already ≤ 0.20 with all minimums met: Skip interview — derive SPEC.md directly from roadmap + requirements. Log: [auto] Phase requirements are already sufficiently clear — generating SPEC.md from existing context. Jump to Step 5.5.

Otherwise: Continue to Step 4.

Step 4: Socratic Interview Loop

Max 6 rounds. Each round: 2–3 questions max. End round after user responds.

Round selection by perspective:

  • Round 1: Researcher
  • Round 2: Researcher + Simplifier
  • Round 3: Boundary Keeper
  • Round 4: Failure Analyst
  • Rounds 5–6: Seed Closer (focus on lowest-scoring dimensions)

After each round:

  1. Update all 4 dimension scores from the user's answers
  2. Calculate new ambiguity score
  3. Display the updated scoring:
After round [N]:
  Goal Clarity:       [score] (min 0.75) [✓ or ↑ needed]
  Boundary Clarity:   [score] (min 0.70) [✓ or ↑ needed]
  Constraint Clarity: [score] (min 0.65) [✓ or ↑ needed]
  Acceptance Criteria:[score] (min 0.70) [✓ or ↑ needed]
  Ambiguity: [score] (gate: ≤ 0.20)

Gate check after each round:

If gate passes (ambiguity ≤ 0.20 AND all minimums met):

If --auto: Jump to Step 5.5.

Otherwise: AskUserQuestion:

  • header: "Spec Gate Passed"
  • question: "Ambiguity is [score] — requirements are clear enough to write SPEC.md. Proceed?"
  • options:
    • "Yes — write SPEC.md" → Jump to Step 5.5
    • "One more round" → Continue interview
    • "Done talking — write it" → Jump to Step 5.5

If max rounds reached (6) and gate not passed:

If --auto: Write SPEC.md anyway — flag unresolved dimensions. Log: [auto] Max rounds reached. Writing SPEC.md with [N] dimensions below minimum. Planner will need to treat these as assumptions.

Otherwise: AskUserQuestion:

  • header: "Max Rounds"
  • question: "After 6 rounds, ambiguity is [score]. [List dimensions still below minimum.] What would you like to do?"
  • options:
    • "Write SPEC.md anyway — flag gaps" → Write SPEC.md, mark unresolved dimensions in Ambiguity Report
    • "Keep talking" → Continue (no round limit from here)
    • "Abandon" → Exit without writing

If --auto mode throughout: Replace all AskUserQuestion calls above with Claude's recommended choice. Log decisions inline. Apply the same logic as --auto in discuss-phase.

Text mode (workflow.text_mode: true or --text flag): Use plain-text numbered lists instead of AskUserQuestion TUI menus.

Step 5: (covered inline — ambiguity scoring is per-round)

Step 5.5: Edge-Completeness Probe

Run AFTER the ambiguity gate passes (you probe edges of clear requirements, not vague ones). Reference: @~/.claude/gsd-core/references/edge-probe.md.

Non-English projects — the probe input is translated, the SPEC is not. The shape cues the classifier matches are English word-boundary patterns, so requirement prose written in another language matches nothing, classifies to zero shapes, and lands every row in unclassified (#1110) — the taxonomy contributes nothing and --auto leaves it all unresolved. The $REQS_JSON payload below is engine input, never user-facing output, so the response_language rule at the top of this workflow does not govern it: write each requirement's text as a faithful English translation of the SPEC requirement. The SPEC keeps the original language — only the probe input is translated, and requirement ids are never translated or renumbered (coverage rows join back on id, and any Acceptance Criteria you write from the resolved edges go into the SPEC in response_language). Translate every requirement, not only the ones that look edge-relevant: the $APPLICABLE = 0 warning below fires only when all requirements are unclassified, so a partly-classified spec slips through with no signal at all. If a requirement still classifies to zero shapes after translation, it carries no cue in any language (the recorded recall gap — ADR-857 §98 / ADR-550 D7b, not a translation failure); author an explicit shapes array on that requirement instead of relying on the prose classifier.

Runtime coverage compute — resolve and invoke edge-probe.cjs:

# Resolve the compiled edge-probe.cjs against the GSD install dir via RUNTIME_DIR (#448)
# — NOT the consuming project's git root — falling back to git toplevel / $HOME/.claude.
# Mirrors the ui-safety-gate.cjs resolution idiom at autonomous.md:290 / plan-phase.md:631.
_GSD_RT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
EDGE_PROBE_JS=$(for _c in \
  "$_GSD_RT/gsd-core/bin/lib/edge-probe.cjs" \
  "$_GSD_RT/bin/lib/edge-probe.cjs" \
  "$_GSD_RT/.claude/bin/lib/edge-probe.cjs" \
  "$HOME/.claude/gsd-core/bin/lib/edge-probe.cjs" \
  "$HOME/.claude/bin/lib/edge-probe.cjs"; do
  [ -f "$_c" ] && { echo "$_c"; break; }
done)

# Graceful degradation — never silent skip (RR-04). Build ONLY when $_GSD_RT is a verified
# GSD source checkout (has tsconfig.build.json + src/edge-probe.cts), and pin npm to it with
# --prefix so we never trigger the CONSUMING project's own build:lib (its cwd package scripts:
# codegen/migrations/writes) during a spec workflow. Real installs ship the compiled .cjs via
# prepublishOnly, so this build path only matters in a GSD dev checkout (review High).
if [ -z "$EDGE_PROBE_JS" ]; then
  if [ -f "$_GSD_RT/tsconfig.build.json" ] && [ -f "$_GSD_RT/src/edge-probe.cts" ]; then
    npm --prefix "$_GSD_RT" run build:lib 2>/dev/null || true
    EDGE_PROBE_JS=$(for _c in \
      "$_GSD_RT/gsd-core/bin/lib/edge-probe.cjs" \
      "$_GSD_RT/bin/lib/edge-probe.cjs" \
      "$_GSD_RT/.claude/bin/lib/edge-probe.cjs" \
      "$HOME/.claude/gsd-core/bin/lib/edge-probe.cjs" \
      "$HOME/.claude/bin/lib/edge-probe.cjs"; do
      [ -f "$_c" ] && { echo "$_c"; break; }
    done)
  fi
  if [ -z "$EDGE_PROBE_JS" ]; then
    echo "ERROR: edge-probe.cjs not found — reinstall GSD or run \`npm run build:lib\` in your GSD checkout." >&2
    exit 1
  fi
fi

# Write the Requirements gathered in THIS spec session to a temp JSON, then invoke the
# canonical coverage compute. Populate the heredoc from the SPEC's Requirements — one object
# per requirement: {"id","text","shapes"?}. This is the load-bearing step: an empty file makes
# the probe a no-op, so the guard below fails loud rather than silently skipping (RR-04).
# When `response_language` is set, `text` carries a faithful ENGLISH translation (see above) —
# the shape cues are English-only, so original-language prose classifies to zero shapes and the
# probe becomes a silent no-op. Keep every `id` exactly as it appears in the SPEC.
# BSD/macOS mktemp only randomizes XXXXXX when it is the final path component, so make a
# suffixless temp then append the extension — portable across BSD + GNU (#1520).
REQS_JSON=$(mktemp "${TMPDIR:-/tmp}/edge-probe-reqs-XXXXXX") && mv "$REQS_JSON" "${REQS_JSON}.json" && REQS_JSON="${REQS_JSON}.json" || exit 1
cat > "$REQS_JSON" <<'JSON'
[
  { "id": "R1", "text": "<replace: requirement text from the SPEC>" }
]
JSON
# Guard — never invoke on an empty/invalid array, OR one still holding the heredoc
# `<replace: …>` placeholder (a forgotten substitution would otherwise yield a
# meaningful-looking but bogus coverage report). Fail loud, not silent no-op.
if ! node -e 'const a=require(process.argv[1]);if(!Array.isArray(a)||a.length===0)process.exit(1);if(a.some(r=>typeof r.text!=="string"||!r.text.trim()||r.text.includes("<replace:")))process.exit(1)' "$REQS_JSON" 2>/dev/null; then
  rm -f "$REQS_JSON"
  echo "ERROR: edge-probe requirements JSON is empty/invalid or still holds the <replace: …> placeholder — populate \$REQS_JSON from the SPEC Requirements before Step 5.5 runs." >&2
  exit 1
fi
# Invoke the compiled engine and CAPTURE its report — it computes which categories apply per
# requirement. The report is RENDERED into context below (#3102); its resolved/dismissed/
# unresolved rows (resolved items carry verification: explicit|backstop) are the deterministic
# FLOOR the resolution loop consumes and unions with its own classification — the loop no longer
# re-derives the taxonomy from prose unaided. Floor, never ceiling: the classifier has a measured
# recall gap (ADR-857 §98 / ADR-550 D7b), so the model still ADDS any category the engine missed.
# The engine FAILS CLOSED (exit 2) on an invalid authored shape or bad input — so the capture
# MUST be exit-checked. A bare `COVERAGE=$(node …)` swallows that exit code, leaves $COVERAGE
# empty, and lets the workflow fall through to prose re-derivation: fail-OPEN at the boundary
# the engine validation exists to protect. Make the run fatal, then validate the captured
# report is well-formed JSON before the resolution loop consumes it.
if ! COVERAGE=$(node "$EDGE_PROBE_JS" "$REQS_JSON"); then
  rm -f "$REQS_JSON"
  echo "ERROR: edge-probe engine failed (invalid shapes or bad input) — fix the requirement(s) and re-run; never proceed with empty coverage." >&2
  exit 1
fi
rm -f "$REQS_JSON"
# Exit-0-but-garbage guard: the report must parse as JSON with the expected { items[], coverage{} } shape.
if ! printf '%s' "$COVERAGE" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let r;try{r=JSON.parse(s)}catch{process.exit(1)}if(!r||!Array.isArray(r.items)||typeof r.coverage!=="object"||r.coverage===null)process.exit(1)})'; then
  echo "ERROR: edge-probe produced an unparseable or malformed coverage report — refusing to proceed with the resolution loop." >&2
  exit 1
fi
# Render the validated report into the model's visible context (#3102). Until here $COVERAGE was
# captured, shape-checked, and reduced to coverage.applicable — the engine's per-requirement
# items[] never reached the model, so the resolution loop below re-derived edge categories from
# requirement PROSE (the data-flow twin of #2733's control-flow discard). These rows are the
# deterministic FLOOR the resolution loop consumes. Printed RAW (not a bespoke table) so this
# step holds NO knowledge of the item schema: an ADR-550 D7a-style re-cut of the item/coverage
# shape cannot silently desync a hand-rolled renderer here — the engine stays the single source.
echo "### Edge-probe coverage report (deterministic proposals — the FLOOR for the resolution loop below):"
printf '%s\n' "$COVERAGE"
echo "### (end edge-probe coverage report)"
# Zero-applicable guard: a report where the engine proposed NO applicable edge across ANY
# requirement is far more likely a shape-classification miss (or malformed requirements) than
# a genuinely edge-free spec — the same fail-open shape as an invalid shape yielding
# applicable:0. Surface it loudly; the author must explicitly confirm "no applicable edges"
# below rather than silently emitting a green empty ## Edge Coverage section.
APPLICABLE=$(printf '%s' "$COVERAGE" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let n=0;try{n=JSON.parse(s).coverage.applicable}catch{n=0}process.stdout.write(String(n))})')
if [ "$APPLICABLE" = "0" ]; then
  echo "WARNING: edge-probe proposed ZERO applicable edges across all requirements — likely a classification miss or malformed requirements, not a genuinely edge-free spec. Do NOT silently write an empty Edge Coverage section." >&2
fi

If $APPLICABLE is 0, do NOT proceed silently: ask the author to confirm via AskUserQuestion ("The edge probe found no applicable edges for any requirement — is this genuinely an edge-free spec, or should we revisit the requirement wording / authored shapes?"). Only write an empty ## Edge Coverage section after explicit confirmation.

For each Requirement gathered so far:

  1. Start from the edge-probe rows RENDERED above — the deterministic items[] are the FLOOR: every proposed (requirement_id, category) MUST be resolved below (Specify / Dismiss-with- reason / Backstop / Defer), none silently dropped. Then raise any applicable category the engine MISSED — the rows are a floor, never a ceiling: the classifier has a measured recall gap on terse prose (ADR-857 §98 / ADR-550 D7b), e.g. a CSV-export requirement whose encoding edge the shape cue under-fires. Union the engine's rows with your own classification (relevance filter — see the taxonomy in the reference); do not narrow to them. Reuse any edges the Round-4 Failure Analyst already surfaced as pre-resolved.
  2. For each raised category, propose a CONCRETE candidate edge (not "consider boundaries" — e.g. "R2 merges intervals; what about [[1,2],[2,3]] that only touch?").
  3. Resolve each with the user (AskUserQuestion; text mode → numbered list):
    • Specify it → write a new pass/fail line into Acceptance Criteria AND mark the edge resolved with verification: explicit.
    • Dismiss (reason) → mark dismissed with a required non-empty reason.
    • Backstop with a test → mark resolved with verification: backstop; note "held-out edge test" for plan-phase.
    • Defer → leave unresolved.
    • An unclassified row (probe unclassified — review manually) means the requirement's prose matched no shape cue (#1110) — treat it like any other candidate (Specify, Dismiss (reason), or Defer). A manual-review nudge, not a hard block.

Soft gate (after resolving):

  • All applicable edges resolved → proceed to Step 5.6.
  • Any unresolved → AskUserQuestion:
    • header: "Edge Coverage"
    • question: "[N] edge(s) are unresolved: [list]. What do you want to do?"
    • options: "Resolve now" (loop back) / "Write SPEC.md anyway — flag unresolved" / "Keep probing"
    • On "anyway": write SPEC.md with those rows marked ⚠ Edge unresolved — planner must treat as assumption.

--auto mode: resolve over the same rendered floor (#3102) — every engine-proposed row from Step 5.5's report (step 1) plus any category the classifier missed, never a narrower set. For each: auto-resolved (verification: explicit) where a defensible acceptance criterion can be written; otherwise auto-resolved (verification: backstop) (never auto-dismiss — a wrong dismissal is the exact silent failure being eliminated). Log: [auto] edge coverage: E explicit, B backstop, U unresolved.

unclassified exception (#1110): --auto leaves an unclassified candidate unresolved (the soft gate surfaces it as a flagged planner assumption) — it never auto-resolves it with verification: backstop. A missing shape is not evidence an edge exists, so minting a held-out edge obligation on a requirement that may be genuinely edge-free would be a false claim and risks a vacuous edge test. Leaving it unresolved keeps the zero-cue requirement visible (never a silent drop) without fabricating an edge — which is exactly #1110's purpose: surface it for review, do not auto-handle it.

Populate the ## Edge Coverage section of SPEC.md from the resolved edges.

Step 5.6: Prohibition-Completeness Probe (must-NOT)

Run AFTER Step 5.5 (you probe the must-NOT axis of clear requirements, over the same requirement list). Reference: @~/.claude/gsd-core/references/prohibition-probe.md — the portable two-stage protocol, the canon-referral rule, and the status×verification schema live there (size-cap discipline; keep this step lean).

D1 — no compiled engine (ADR-550 D7b). Unlike Step 5.5, the prohibition probe has NO compiled recall engine and runs NO node invocation here. The recall stage is an LLM prose pass: the closed eight-category edge taxonomy a classifier can apply does not exist for the open values/safety/ethics must-NOT axis. Do NOT copy the Step 5.5 engine-resolution block. Only the schema/projection layer is real code; the recall is prose.

For each Requirement gathered so far, run the two-stage recall→precision pass:

  1. Stage 1 — Recall (adversarial prose probe). Ask the single adversarial question of the requirement: "What could this feature silently become that the author would NOT want, but the spec does not forbid?" Over-produce (~10 raw must-NOT candidates) — recall first.
  2. Stage 2 — Precision (one-pass classifier). Filter the raw list in a single pass: DROP routine-engineering items (normal correctness/hygiene — "must not mutate input", "must not throw on empty" — owned by the edge probe or code review); KEEP values / safety / ethics items (manipulative framing, protected-attribute proxies, raw PII in plaintext). This collapses ~10 → ~2–3 genuine prohibitions.
  3. Canon-referral (ADR-550 D6, PROB-13). A kept candidate that is canon security/compliance (OWASP / prototype-pollution / path-traversal / injection / GDPR / generic fairness) is NOT minted here — emit a one-line breadcrumb ("prototype-pollution is canon — owned by /gsd:secure-phase + eslint; not minted here") and DROP it. Minting canon items duplicates /gsd:secure-phase and drowns the bespoke signal.
  4. Resolve each surfaced (non-canon) prohibition (AskUserQuestion; text mode → numbered list):
    • Keep it → write a NEGATIVE acceptance criterion (a must-NOT line) into Acceptance Criteria AND mark the prohibition resolved with a verification tier: test (a mechanical negative test/lint/assertion exists) or judgment (real but not mechanically checkable — routes to judgment review).
      • Capture the wired-check descriptor on test-tier (#1278, SOFT). When a prohibition is resolved verification: test, ALSO capture the descriptor of the wired check so verify-phase can LOCATE it deterministically (no verifier invention at verify time). Capture the flat scalars — persisted into SPEC and projected onto the must_haves.prohibitions item by projectProhibitions:
        • check_kind — node-test | lint-rule.
        • check_target — the negative-test file path (for node-test), or the path to lint (for lint-rule).
        • check_rule — the eslint rule id (e.g. local/no-source-grep); lint-rule only.
        • check_violation_fixture (#1279) — path to a KNOWN-BAD subject the wired check is run against to machine-prove fail-first; rides BOTH kinds. Capture it to let the item green end-to-end with zero hand-authoring at verify time; for node-test the negative test should read its subject from the GSD_PROHIB_SUBJECT env var so the prover can inject this fixture.
        • check_clean_fixture (#1346; REQUIRED for node-test as of #1906) — path to a KNOWN-CLEAN control subject. The node-test prover runs the check against it and requires GREEN — proving the violation's RED is caused by the subject's content, not by GSD_PROHIB_SUBJECT merely being set. For a node-test this is mandatory: omit it and the check is un-provable (fail-closed), never proven on the violation alone — so a deceptive content-independent test cannot pass. (lint-rule needs no clean fixture: its subject IS the linted file, no GSD_PROHIB_SUBJECT indirection.) This is a SOFT capture (CHK-04): a test-tier prohibition WITHOUT a descriptor is still allowed — if the author cannot yet name the wired check, leave the descriptor empty and proceed. It is NOT a hard authoring block; the item simply stays fail-closed/flagged downstream (an absent/partial descriptor — or one with no check_violation_fixture — → descriptorFromProjection null/under-specified/fixture-less → producer fail-closed locate-or-unprovable, never green). Do NOT capture failFirst here — it is a verify-time caller attestation, not a spec-authored field (#1279).
    • Dismiss (reason) → mark dismissed with a REQUIRED non-empty reason (PROB-05). The reason string is the audit trail; silence is not a valid dismissal.
    • Defer → leave unresolved.

Soft gate (after resolving) — PROB-06:

  • All applicable prohibitions resolved → proceed to Step 6.
  • Any unresolved → AskUserQuestion:
    • header: "Prohibitions"
    • question: "[N] prohibition(s) are unresolved: [list]. What do you want to do?"
    • options: "Resolve now" (loop back) / "Write SPEC.md anyway — flag unresolved" / "Keep probing"
    • On "anyway": write SPEC.md with those rows marked ⚠ Prohibition unresolved — planner must treat as assumption. This is a soft gate (write-anyway-with-flags), never a silent skip — the soft gate IS the control.

--auto mode: auto-resolved where a defensible negative acceptance criterion can be written (test or judgment tier); otherwise leave unresolved. --auto NEVER auto-dismisses a prohibition — a wrong dismissal is the exact silent failure this probe eliminates (PROB-06, the load-bearing safety property). On a test-tier auto-resolution, capture the check_kind / check_target / check_rule / check_violation_fixture / check_clean_fixture descriptor only when a wired check is unambiguous; otherwise leave it empty — --auto NEVER fabricates a check path or fixture (a wrong locate is re-validated and fails closed at the producer, but a fabricated path is still noise to avoid). Log: [auto] prohibitions: R resolved, U unresolved.

Text mode (PROB-09): per Step 5's text-mode rule, replace the AskUserQuestion menus above with plain-text numbered lists — there is NO hard AskUserQuestion dependency, so the probe runs identically for non-Claude / text-mode hosts.

Populate the ## Prohibitions section of SPEC.md from the resolved prohibitions (each resolved/test row is a checkable negative acceptance criterion; resolved/judgment rows route to judgment review; ⚠ UNRESOLVED rows are flagged as assumptions). A resolved/test row ALSO carries its captured check_kind / check_target / check_rule / check_violation_fixture / check_clean_fixture descriptor when present (so the projection feeds verify-phase's deterministic locate + machine-proof + causation control, #1278 + #1279 + #1346); a test row with no captured descriptor is still valid — it stays fail-closed/flagged downstream rather than blocking authoring.

Step 6: Generate SPEC.md

Use the SPEC.md template from @~/.claude/gsd-core/templates/spec.md.

  • Populate the Edge Coverage section from Step 5.5 (resolved/dismissed/unresolved rows; resolved items carry verification: explicit|backstop).
  • Populate the Prohibitions section from Step 5.6 (resolved/dismissed/unresolved rows with the test|judgment tier).

Requirements for every requirement entry:

  • One specific, testable statement
  • Current state (what exists now)
  • Target state (what it should become)
  • Acceptance criterion (how to verify it was met)

Vague requirements are rejected:

  • ✗ "The system should be fast"
  • ✗ "Improve user experience"
  • ✓ "API endpoint responds in < 200ms at p95 under 100 concurrent requests"
  • ✓ "CLI command exits with code 1 and prints to stderr on invalid input"

Count requirements. The display in discuss-phase reads: "Found SPEC.md — {N} requirements locked."

Boundaries must be explicit lists:

  • "In scope" — what this phase produces
  • "Out of scope" — what it explicitly does NOT do (with brief reasoning)

Acceptance criteria must be pass/fail checkboxes — no "should feel good" or "looks reasonable."

If any dimensions are below minimum, mark them in the Ambiguity Report with: ⚠ Below minimum — planner must treat as assumption.

Write to: {phase_dir}/{padded_phase}-SPEC.md

Step 7: Commit

gsd_run query commit "spec(phase-${phase_number}): add SPEC.md for ${phase_name} — ${requirement_count} requirements (#2213)" --files "${phase_dir}/${padded_phase}-SPEC.md"

If commit_docs is false the CLI returns skipped; SPEC.md is written, not committed.

Step 8: Wrap Up

Display:

SPEC.md written — {N} requirements locked.

  Phase {X}: {name}
  Ambiguity: {final_score} (gate: ≤ 0.20)

Next: /gsd:discuss-phase {X}
  discuss-phase will detect SPEC.md and focus on implementation decisions only.

<critical_rules>

  • Every requirement MUST have current state, target state, and acceptance criterion
  • Boundaries section is MANDATORY — cannot be empty
  • "In scope" and "Out of scope" must be explicit lists, not narrative prose
  • Acceptance criteria must be pass/fail — no subjective criteria
  • SPEC.md is NEVER written if the user selects "Abandon"
  • Do NOT ask about HOW to implement — that is discuss-phase territory
  • Scout the codebase BEFORE the first question — grounded questions only
  • Max 2–3 questions per round — do not frontload all questions at once
  • Step 5.5 edge probe runs after the ambiguity gate; dismissals require a reason; --auto never auto-dismisses
  • Step 5.6 prohibition probe runs after the edge probe; dismissals require a reason; --auto never auto-dismisses a prohibition </critical_rules>

<success_criteria>

  • Codebase scouted and current state understood before questioning
  • All 4 dimensions scored after every round
  • Gate passed OR user explicitly chose to write despite gaps
  • SPEC.md contains only falsifiable requirements
  • Boundaries are explicit (in scope / out of scope with reasoning)
  • Acceptance criteria are pass/fail checkboxes
  • SPEC.md committed atomically (when commit_docs is true)
  • User directed to /gsd:discuss-phase as next step
  • Edge-completeness probe run; Edge Coverage section populated; unresolved edges flagged as assumptions
  • Prohibition-completeness probe run; Prohibitions section populated; unresolved prohibitions flagged as assumptions </success_criteria>