* test(#2684): failing-first guard for unbound model= dispatch placeholders Extends the #2517 omit-on-inherit bucket with a behavioral binding guard: every model="{X}" in a workflow must name a field that workflow actually binds — an init-payload key (queried for real), a shell assignment, or a declared parse field. scan.md ({resolved_model}) and ship.md ({balanced_model}) substitute names nothing emits, so the orchestrator invents the value (ADR-1411). Also pins the shipped reference that seeded the placeholder and instructs the #2517-forbidden model="inherit". RED expected on scan.md, ship.md, and references/model-profile-resolution.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso * fix(#2684): bind scan.md and ship.md model dispatch to fields their workflows resolve scan.md:85 passed model="{resolved_model}" and ship.md:486 passed model="{balanced_model}" — names neither workflow's init payload emits. init.map-codebase emits mapper_model; init.phase-op emits no model field at all. With no source for the substitution the orchestrator invents a value, so model_overrides/model_policy are silently inert at both sites — the invisible partial application ADR-1411 prohibits. - scan.md: declare the init fields in a Parse JSON line and substitute {mapper_model}, matching its sibling map-codebase.md. - ship.md: ref.agent is only known at runtime, so resolve it per hook via query resolve-model and dispatch with {HOOK_AGENT_MODEL}, following the same NAME=$(...) → model="{NAME}" convention every other shell-resolved dispatch in the corpus uses (code-review.md, secure-phase.md, ui-phase.md). - Both sites now carry the #2517 rule: omit model= entirely when the resolved value is "inherit" or empty. A bare rename would have traded a dangling placeholder for model="", which 404s on non-Claude runtimes — an agent type absent from the profile table resolves to the empty string, which is exactly ship.md's case. - references/model-profile-resolution.md was the seam: it shipped the copy-pasteable {resolved_model} snippet scan.md inherited, used the stale Task( spelling, and instructed passing model="inherit" outright. Rewritten to teach the real binding convention and the omit rule. Two defects surfaced while fixing this and fixed inline rather than deferred: 1. ref.agent originates in a capability manifest, which may be third-party. Resolving it at runtime made this the first place that value reaches a shell command, so ship.md now validates its shape before interpolating and skips the hook when it fails — matching code-review.md's existing defense-in-depth pattern. Covered by a test that runs the shipped regex against real agent names and injection payloads. 2. The reference doc's omit example first placed a literal model= inside an Agent(...) comment, which tripped the #2284 fail-closed Hermes projection guard (bin/install.js:3704) and refused the install outright. Moved out of the call span; gen:golden is green across all 19 runtimes. Guard tests extend the existing #2517 bucket: every model="{X}" in a workflow must name a field that workflow binds — an init-payload key queried for real, a shell assignment, or a declared parse field. Behavior change (Hyrum's Law): the scan mapper now runs on the catalog-resolved model rather than the session model. The omit path is unchanged. Disclosed in the changeset body. Fixes #2684 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso * fix(#2684): validate capability-supplied ref.agent in-context, not in the shell The first cut of the ref.agent guard was placed after the injection point it was meant to close. It instructed the orchestrator to substitute the untrusted manifest value into a shell assignment and test it there: HOOK_AGENT="<the ref.agent value>" if [[ "$HOOK_AGENT" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then … Substitution happens before bash parses anything, so a manifest supplying `x"; touch /tmp/pwned; echo "` yields three statements and runs the middle one unconditionally — the regex fires afterwards and protects nothing. ship.md now requires the check to run in-context, the same way the workflow already reads activeHooks ("do NOT pipe it through a shell parser"), and to skip the hook outright on a mismatch. Only a value that has already matched ^[A-Za-z0-9][A-Za-z0-9._-]*$ ever reaches a command line. The guard test asserts the ordering — the in-context requirement and the absence of any raw shell assignment — not just that the pattern rejects metacharacters, since a pattern alone was exactly what gave false assurance here. Also corrects the empty-string explanation in references/model-profile- resolution.md. model_profile:"inherit" resolves to the literal "inherit", not "" (model-resolver.cts:395), and an unknown agent takes the empty-string path via resolve_model_ids:"omit" rather than by absence alone — the doc claimed all three produced "". The workflow instructions were already correct (omit on "inherit" OR empty); only the rationale in the citable reference was wrong. Both found by the isolated adversarial review pass for #2684. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso * chore(#2684): backfill changeset PR number Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
8.7 KiB
<required_reading> Read all files referenced by the invoking prompt's execution_context before starting. </required_reading>
<available_agent_types> Valid GSD subagent types (use exact names — do not fall back to 'general-purpose'):
- gsd-codebase-mapper — Maps project structure and dependencies </available_agent_types>
Focus-to-Document Mapping
| Focus | Documents Produced |
|---|---|
tech |
STACK.md, INTEGRATIONS.md |
arch |
ARCHITECTURE.md, STRUCTURE.md |
quality |
CONVENTIONS.md, TESTING.md |
concerns |
CONCERNS.md |
tech+arch |
STACK.md, INTEGRATIONS.md, ARCHITECTURE.md, STRUCTURE.md |
Step 1: Parse arguments and resolve focus
Parse the user's input for --focus <area>. Default to tech+arch if not specified.
Validate that the focus is one of: tech, arch, quality, concerns, tech+arch.
If invalid:
Unknown focus area: "{input}". Valid options: tech, arch, quality, concerns, tech+arch
Exit.
Step 2: Check for existing documents
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
INIT=$(gsd_run query init.map-codebase 2>/dev/null || echo "{}")
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
Parse JSON for: mapper_model, commit_docs, search_gitignored, parallelization, subagent_timeout, date, codebase_dir, existing_maps, has_maps, planning_exists, codebase_dir_exists.
Look up which documents would be produced for the selected focus (from the mapping table above).
For each target document, check if it already exists in .planning/codebase/:
ls -la .planning/codebase/{DOCUMENT}.md 2>/dev/null
If any exist, show their modification dates and ask:
Existing documents found:
- STACK.md (modified 2026-04-03)
- INTEGRATIONS.md (modified 2026-04-01)
Overwrite with fresh scan? [y/N]
If user says no, exit.
Step 3: Create output directory
mkdir -p .planning/codebase
Step 4: Spawn mapper agent
Spawn a single gsd-codebase-mapper agent with the selected focus area:
Print: ◆ Spawning scanner... (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)
Runtime-aware dispatch (#2508 Phase 4). GSD workflows dispatch specialized subagents by role. Before dispatching on a built-in-only runtime (kimi-code — three built-ins only), resolve the role to a built-in via
gsd_run query resolve-dispatch-type --requested <role> --raw. On named-dispatch runtimes (Claude/OpenCode/…) the role is returned unchanged; on kimi-code it maps tocoder/explore/planby role-suffix. The persona rides${AGENT_SKILLS_<ROLE>}(Phase 3) regardless. See @gsd-core/references/runtime-aware-dispatch.md.
#2517 model resolution: mapper_model is the field init.map-codebase emits (parsed in Step 2) — this is the same binding map-codebase.md uses. Omit the model= parameter entirely when mapper_model is "inherit" or empty; do NOT pass model="" or model="inherit", which 404s on non-Claude runtimes. Omitting inherits the orchestrator's model.
Agent(
prompt="Scan this codebase with focus: {focus}. Write results to {codebase_dir}/. Produce only: {document_list}",
subagent_type="gsd-codebase-mapper",
model="{mapper_model}"
)
ORCHESTRATOR RULE — CODEX RUNTIME: After calling Agent() above, stop working on this task immediately. Do not read more files, edit code, or run tests related to this task while the subagent is active. Wait for the subagent to return its result. This prevents duplicate work, conflicting edits, and wasted context. Only resume when the subagent result is available.
Step 5: Report
## Scan Complete
**Focus:** {focus}
**Documents produced:**
{list of documents written with line counts}
Use `/gsd:map-codebase` for a comprehensive 4-area parallel scan.
<success_criteria>
- Focus area correctly parsed (default: tech+arch)
- Existing documents detected with modification dates shown
- User prompted before overwriting
- Single mapper agent spawned with correct focus
- Output documents written to .planning/codebase/ </success_criteria>