* feat(#3841): assert gsd-tools identity before any state-mutating verb only this package publishes. The path-based branches — a project-local install, a runtime config directory — had no such guarantee; they trusted their configured location. This closes them. Mechanism: once resolution finishes, and before any verb runs, the preamble probes the tool it picked with `runtime-identity --raw` and matches the answer with a shell `case` pattern ANCHORED to the start of the compact payload (`{"packageName":"@opengsd/gsd-core"`). An unanchored substring match accepts the decoy `{"packageName":"get-shit-done-cc","note":"@opengsd/gsd-core"}`, which any colliding package could publish. The outcome is exported as the two-valued `GSD_IDENTITY_STATUS` (`ok`/`unverified`), so the gate is asserted on a VALUE rather than on warning prose. Rollout is warn-then-fail per the #3146 ruling: `unverified` prints one line naming BOTH causes and continues, because `no_identity_verb` cannot tell a foreign package from an `@opengsd/gsd-core` older than the verb, and at rollout the old-version case is the common one. The blocker was byte budget, not design. The preamble is inlined into 112 shipped files and several sat within single-digit bytes of frozen ceilings (`gsd-verifier.md` 16 bytes, `gsd-executor.md` 33, `execute-phase.md` 234); a first attempt broke five of them. What made room was collapsing the resolver's twenty near-identical `elif [ -f … ]` arms into one candidate-list helper (`_gsd_at`), which buys far more than the assertion costs. The preamble is now 2,624 bytes against 4,500 — a net 1,876 bytes SMALLER per inlined file, so every capped file moved away from its ceiling rather than toward it. No cap raised, no size-budget exception added, no override token emitted. Resolution order, every runtime-home probe, the `unset -f gsd_run` re-source fix, the fail-closed `exit 1`, and the `CLAUDE_ENV_FILE` persistence are all preserved byte-for-byte in substring terms; the snippet still begins with `_GSD_SHIM_NAME=` and still ends with `fi`, which the parity extractors anchor on. `gsd-core/references/gsd-run-resolver.md` is re-synced byte-equal. Also fixes two stale claims found in passing: CONTEXT.md and FEATURES.md both described an `[ -x ]` guard as the load-bearing re-source defense. That guard was tried and REMOVED in #3831 — it rejected the bare function name, fell through every branch, and hit `exit 1`, which kills a sourced caller's shell. `unset -f gsd_run` is the actual mechanism. Refs #3841 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3841): pair the anchor's brace by requiring a closed identity payload The matrix went red on `tests/new-project-mvp-prompt.test.cjs` — "new-project.md has unbalanced braces: net depth 2" — plus a knock-on report from its parent `bug #1516` describe, which is the same failure counted once at the child and once at the block. Root cause: that guard (:182-189, mirroring #3784 bd53925f) walks characters and increments on `{`, decrements on `}`, with no awareness of shell quoting. It scans `new-project.md` PLUS every `new-project/steps/*.md`, and both `new-project.md` and `steps/auto-mode-config.md` carry one inlined preamble copy — hence net 2 from a snippet that was off by exactly one. The unpaired brace was the `{` inside the single-quoted `case` pattern of the identity anchor, which is correct shell and invisible to a text scanner. Fix in the snippet, not the guard. The pattern now anchors at BOTH ends: `'{"packageName":"@opengsd/gsd-core"'*'}'`. That balances 51/51 with a brace that does real work rather than a cosmetic pair — a truncated payload whose prefix matches now fails too, where before it verified. Safe for any future additive field: a JSON object's own closing brace is always the last character, whatever type the last value has, which is pinned by two negative-space tests (a nested object and an array-valued last key must both still verify). Cost: +3 bytes, against the 1,873 the resolver fold already gave back. The alternative considered and rejected was dropping the literal `{` for a `?` glob. It balances too, but weakens the anchor from "must be an opening brace" to "must be any one character", and the anchor is the entire point. Two guards added so this cannot recur silently: - runtime-launcher-parity (F0) pins brace balance at the SNIPPET, so the next edit to that pattern fails on the file it broke instead of surfacing three files downstream in a test whose name mentions neither the launcher nor this issue. It also asserts depth never goes negative, since a `}` preceding its `{` nets to zero while being unbalanced at every prefix. - runtime-identity gains behavioral truncated-payload and trailing-garbage fixtures, so the added `}` is proven load-bearing rather than merely present. Verified: snippet 51/51 braces; new-project combined net depth 0; the seven other preamble-bearing files with nonzero depth are unchanged from merged next (their own prose, not the preamble, and not in any guard's scan set); all 112 inlined copies and the resolver reference re-synced byte-equal; sync:launcher idempotent on the second run. Refs #3841 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3841): backfill changeset PR number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
16 KiB
This command is deliberately separate from /gsd:settings (workflow toggles)
and any /gsd-settings-advanced tuning surface. It exists because API keys and
cross-tool routing are connectivity concerns, not workflow or tuning knobs.
- Masking convention:
****<last-4>(e.g.sk-abc123def456→****f456). Strings shorter than 8 characters render as****with no tail so a short secret does not leak a meaningful fraction of its bytes. Unset values render as(unset). - Plaintext is never echoed by AskUserQuestion descriptions, confirmation
tables, or any log line. It is not written to any file under
.planning/other thanconfig.jsonitself. config-setoutput is masked for keys in the secret set (brave_search,firecrawl,exa_search) — seegsd-core/bin/lib/secrets.cjs.- Agent-type and CLI slug validation.
agent_skills.<agent-type>slug inputs are checked against^[a-zA-Z0-9_-]+$before any write; inputs containing path separators (/,\,..), whitespace, or shell metacharacters are rejected. This closes off skill-injection attacks on that open namespace (dynamic key pattern). Forreview.models.<cli>no slug-shape check is needed or performed: the gate is membership in the closed, registry-derived settable set (see the review-models section below), which subsumes slug shape — slug shape alone never makes areview.models.*key writable.
<required_reading> Read all files referenced by the invoking prompt's execution_context before starting. </required_reading>
Ensure config exists and resolve the active config path (flat vs workstream, #2282):_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}"; _gsd_at() { for _p; do if [ -f "$_p" ]; then GSD_TOOLS="$_p"; return 0; fi; done; return 1; }; if _gsd_at "${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif unset -f gsd_run; _G="$(command -v gsd_run)"; then GSD_TOOLS="$_G"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif _gsd_at "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; then 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; GSD_IDENTITY_STATUS=unverified; case "$(gsd_run runtime-identity --raw 2>/dev/null || true)" in '{"packageName":"@opengsd/gsd-core"'*'}') GSD_IDENTITY_STATUS=ok;; esac; export GSD_IDENTITY_STATUS; [ "$GSD_IDENTITY_STATUS" = ok ] || echo "WARNING: \"$GSD_TOOLS\" did not prove it is @opengsd/gsd-core - it is either a different package or an @opengsd/gsd-core older than the runtime-identity verb. See docs/how-to/diagnose-a-foreign-gsd-tools.md" >&2; 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
RESPONSE_LANGUAGE=$(gsd_run query config-get response_language --default "" 2>/dev/null || echo "")
gsd_run query config-ensure-section
if [[ -z "${GSD_CONFIG_PATH:-}" ]]; then
if [[ -f .planning/active-workstream ]]; then
WS=$(tr -d '\n\r' < .planning/active-workstream)
GSD_CONFIG_PATH=".planning/workstreams/${WS}/config.json"
else
GSD_CONFIG_PATH=".planning/config.json"
fi
fi
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.
Store $GSD_CONFIG_PATH. Every subsequent read/write uses it.
(unset)— field is null / missing****<last-4>— secret field that is populated (plaintext never shown)<value>— non-secret routing/skill string, shown as-is
BRAVE=$(gsd_run query config-get brave_search --default null)
FIRECRAWL=$(gsd_run query config-get firecrawl --default null)
EXA=$(gsd_run query config-get exa_search --default null)
SEARCH_GITIGNORED=$(gsd_run query config-get search_gitignored --default false)
For each secret key (brave_search, firecrawl, exa_search) the displayed
value is ****<last-4> when set, never the raw string. Never echo the
plaintext to stdout, stderr, or any log.
Text mode (workflow.text_mode: true or --text flag): Set
TEXT_MODE=true and replace every AskUserQuestion call with a plain-text
numbered list. Required for non-Claude runtimes.
Ask the user what they want to do for each search API key. For keys that are
already set, show **** already set and offer Leave / Replace / Clear. For
unset keys, offer Skip / Set.
AskUserQuestion([
{
question: "Brave Search API key — used for web research during plan/discuss phases",
header: "Brave",
multiSelect: false,
options: [
// When already set:
{ label: "Leave (**** already set)", description: "Keep current value" },
{ label: "Replace", description: "Enter a new API key" },
{ label: "Clear", description: "Remove the stored key" }
// When unset, use the two-option shape: Skip / Set.
]
},
{
question: "Firecrawl API key — used for deep-crawl scraping",
header: "Firecrawl",
multiSelect: false,
options: [ /* same Leave/Replace/Clear or Skip/Set */ ]
},
{
question: "Exa Search API key — used for semantic search",
header: "Exa",
multiSelect: false,
options: [ /* same Leave/Replace/Clear or Skip/Set */ ]
},
{
question: "Include gitignored files in local code searches?",
header: "Gitignored",
multiSelect: false,
options: [
{ label: "No (Recommended)", description: "Respect .gitignore. Safer — excludes secrets, node_modules, build artifacts." },
{ label: "Yes", description: "Include gitignored files. Useful when secrets/artifacts genuinely contain searchable intent." }
]
}
])
For each "Set" or "Replace", follow with a text-input prompt that asks for the key value. The answer must not be echoed back in subsequent question descriptions or confirmation text. Write the value via:
gsd_run query config-set brave_search "<value>" # masked in output
gsd_run query config-set firecrawl "<value>" # masked in output
gsd_run query config-set exa_search "<value>" # masked in output
gsd_run query config-set search_gitignored true|false
For "Clear", write null:
gsd_run query config-set brave_search null
review.models.<cli> is a closed, registry-derived map that tells the review
workflow which model id a reviewer lane uses. It is not an open namespace: a
review.models.<cli> key is settable only when that lane's capability
declares a modelConfigKey, and config-set accepts exactly those keys
(federated from the capability registry). No dynamic-key regex governs this
namespace — any other slug fails with Unknown config key.
Settable keys (the shipped registry's model-bearing lanes):
review.models.agy (Antigravity), review.models.claude, review.models.codex,
review.models.gemini, review.models.kimi-code, review.models.llama_cpp,
review.models.lm_studio, review.models.ollama, review.models.opencode.
Reviewer lanes cursor, qwen, and coderabbit declare no modelConfigKey —
there is nothing to configure for them here (whether they should have a
per-lane model key is a separate question, out of scope for this workflow).
If the user asks for one of those, say exactly that and skip.
AskUserQuestion([
{
question: "Review model CLI mapping — what next?",
header: "Review",
multiSelect: false,
options: [
{ label: "Configure CLI", description: "Pick a reviewer lane and set/clear its model id" },
{ label: "Done", description: "Finish this section" }
]
}
])
If "Configure CLI" is selected, ask:
AskUserQuestion([
{
question: "Which reviewer lane do you want to configure? (Common lanes below; any settable lane from the list above works — or type its slug)",
header: "CLI",
multiSelect: false,
options: [
{ label: "Claude", description: "review.models.claude — defaults to session model when unset" },
{ label: "Codex", description: "review.models.codex — bare model id injected into --model, e.g. 'gpt-5'" },
{ label: "Gemini", description: "review.models.gemini — bare model id injected into -m, e.g. 'gemini-2.5-pro'" },
{ label: "OpenCode", description: "review.models.opencode — bare model id injected into --model, e.g. 'claude-sonnet-4'" }
]
}
])
For a slug received as free text, check it against the settable set above. If it is not one of the settable keys, print:
Rejected: review.models.<slug> is not settable — only the reviewer lanes whose
keys are enumerated above can be configured here. (cursor, qwen, and
coderabbit have no per-lane model key.)
and re-prompt.
For the selected lane, show the current value (or (unset)) and offer
Leave / Replace / Clear, followed by a text-input prompt for the model id
string. Write via:
gsd_run query config-set review.models.<cli> "<model id>"
After each update, return to the "Review model CLI mapping — what next?" question. Loop until the user selects "Done".
agent_skills.<agent-type> injects extra skill names into an agent's spawn
frontmatter. The slug is user-extensible, so input is free-text validated
against ^[a-zA-Z0-9_-]+$. Inputs with path separators, spaces, or shell
metacharacters are rejected.
AskUserQuestion([
{
question: "Agent skills mapping — what next?",
header: "Agent Skills",
multiSelect: false,
options: [
{ label: "Configure agent", description: "Pick an agent type and set/clear skills" },
{ label: "Done", description: "Finish this section" }
]
}
])
If "Configure agent" is selected, ask:
AskUserQuestion([
{
question: "Configure agent_skills for which agent type?",
header: "Agent Type",
multiSelect: false,
options: [
{ label: "gsd-executor", description: "Skills injected when spawning executor agents" },
{ label: "gsd-planner", description: "Skills injected when spawning planner agents" },
{ label: "gsd-verifier", description: "Skills injected when spawning verifier agents" },
{ label: "Custom…", description: "Enter a custom agent-type slug" }
]
}
])
For "Custom…", prompt for a slug and validate it matches
^[a-zA-Z0-9_-]+$. If it fails validation, print:
Rejected: agent-type '<slug>' must match [a-zA-Z0-9_-]+ (no path separators,
spaces, or shell metacharacters).
and re-prompt.
For a selected slug, prompt for the skill list (text input; a comma-separated reply is fine). Show the current value if any, offer Leave / Replace / Clear.
Split the reply before writing: the resolver never splits strings, so a
comma-joined string would be stored as ONE skill path that silently fails
resolution at spawn time (#3651 — gsd-core/references/planning-config.md:
"Paths cannot be comma-joined into one string; each path must be its own
array element"). Split on commas, trim each entry, drop empty entries, reject
any entry containing a quote character (' or " — it cannot be written
safely through the single-quoted form), and write the JSON array form:
gsd_run query config-set agent_skills.<slug> '["skills/alpha","skills/beta"]'
A single skill may be written as one bare string or a one-element array — both resolve identically.
After each update, return to the "Agent skills mapping — what next?" question. Loop until "Done".
Display the masked confirmation table. **No plaintext API keys appear in this output under any circumstance.**### GSD ► INTEGRATIONS UPDATED
Search Integrations
| Field | Value |
|--------------------|-------------------|
| brave_search | ****<last-4> | (or "(unset)")
| firecrawl | ****<last-4> |
| exa_search | ****<last-4> |
| search_gitignored | true | false |
Code Review CLI Routing
| Lane | Model id |
|-------------|--------------------------------------|
| <lane> | <value or (unset)> |
| ... | ... one row per lane the user set |
Agent Skills Injection
| Agent Type | Skills |
|------------------|---------------------------|
| <slug> | <skill-a, skill-b> |
| ... | ... |
Notes:
- API keys are stored plaintext in .planning/config.json. The confirmation
table above never displays plaintext — keys appear as ****<last-4>.
- Plaintext is not echoed back by this workflow, not written to any log,
and not displayed in error messages.
Quick commands:
- /gsd:settings — workflow toggles and model profile
- /gsd-set-profile <profile> — switch model profile
<success_criteria>
- Current config read from
$GSD_CONFIG_PATH - User presented with three sections: Search Integrations, Review CLI Routing, Agent Skills Injection
- API keys written plaintext only to
config.json; never echoed, never logged, never displayed - Masked confirmation table uses
****<last-4>for set keys and(unset)for null agent_skills.<agent-type>slugs validated against[a-zA-Z0-9_-]+before write;review.models.<cli>slugs accepted only from the registry-derived settable set; skill lists written as JSON arrays (never comma-joined strings)- Config merge preserves all keys outside the three sections this workflow owns </success_criteria>