diff --git a/ROADMAP.md b/ROADMAP.md index 5b85eb54fc..6f12dcf261 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6428,3 +6428,41 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 450. **`prompt` emits `kind:"missing_credentials"` JSON on STDERR (not stdout), leaving stdout at 0 bytes — automation pattern `output=$(claw prompt hello --output-format json)` captures nothing on auth-absent failure; `doctor` correctly surfaces `auth.status:"warn"` with `api_key_present:false` but exposes no `prompt_ready:false` field that automation can check before invoking `prompt`** — dogfooded 2026-05-16 by Jobdori on `a35ee9a0` in response to Clawhip pinpoint nudge at `1505208225321062521`. Exact reproduction (isolated env, no creds, fresh git repo, HEAD `a35ee9a0`): `timeout 5 env -i HOME=$ISOLATED_HOME PATH=$PATH CLAW_CONFIG_HOME=$PROBE/.claw-cfg claw prompt hello --output-format json > stdout.txt 2> stderr.txt` → stdout = **0 bytes**, stderr = 195 bytes containing `{"error":"missing Anthropic credentials…","exit_code":1,"hint":null,"kind":"missing_credentials","type":"error"}`, exit code 1. Confirms Gaebal's `1505208553793781792` pinpoint that `prompt` timeout + zero bytes was the prior state — HEAD `a35ee9a0` now correctly exits 1 with `kind:"missing_credentials"` **but the envelope is still routed to stderr** (issue #447 class, same class as prior entries #422, #435). **Contrast with `doctor`:** `claw doctor --output-format json 2>/dev/null` succeeds to stdout with `checks[auth].status:"warn"`, `api_key_present:false`, `auth_token_present:false` — but the auth check has no `prompt_ready:false` field. Automation that gates on `doctor` before invoking `prompt` must re-derive readiness from `api_key_present && auth_token_present` — there is no single canonical boolean. **Three compound problems:** (a) **stdout-empty on `--output-format json` failure**: same class as #447; `prompt`'s error envelope goes to stderr, not stdout. The canonical automation idiom `if ! result=$(claw prompt "q" --output-format json); then echo "$result" | jq .kind; fi` sees `$result=""` on failure — the jq call gets nothing. All `--output-format json` error paths must route JSON to stdout per #447 contract; (b) **`doctor` missing `prompt_ready` field**: `doctor --output-format json` already knows auth is absent (`api_key_present:false`) but surfaces no derived `prompt_ready:bool` or `prompt_blocked_reason:string` field. Automation must infer readiness from `api_key_present || auth_token_present || legacy_*_present` — a 5-field OR across legacy fields that is fragile as auth mechanisms evolve. A single `prompt_ready:false` (with `prompt_blocked_reason:"auth_missing"`) inside the `auth` check would give downstream a stable contract; (c) **`claw prompt` with no auth does no preflight and fires straight at the API**: the preflight check that `doctor` runs (auth discovery) is not reused by `prompt` to emit a fast typed error before attempting the network call. Both Gaebal's pinpoint (prompt hanging silently on older HEAD) and the current behavior (prompt hitting auth gate after a brief API attempt) stem from the same root: prompt does not short-circuit at the point where `doctor` already knows auth is absent. If `doctor` can emit `kind:"doctor"` with `auth.status:"warn"` in ~20ms without a network call, `prompt` should emit `kind:"missing_credentials"` in the same window and output it to stdout. **Required fix shape:** (a) `prompt --output-format json` must write the `kind:"missing_credentials"` JSON envelope to **stdout**, not stderr — same fix as #447 for all error envelopes; (b) add `prompt_ready:bool` and `prompt_blocked_reason:string|null` to the `auth` check in `doctor --output-format json`; derive it as `api_key_present || auth_token_present || legacy_saved_oauth_present`; (c) `prompt` must run the credential preflight check (same codepath as doctor's auth check) before attempting any API call and emit `{"kind":"missing_credentials","prompt_blocked_reason":"auth_missing"}` on **stdout** with exit 1 if the check fails; (d) `--output-format json` stdout routing fix must cover: `prompt`, `session list` (cross-ref #449), `skills uninstall` (cross-ref #431), `resume` (cross-ref #435), `acp serve` (cross-ref #443) — the full `kind:"missing_credentials"` class; (e) regression test: `claw prompt hello --output-format json` with no creds writes JSON to stdout (0 bytes stderr), exits 1, `kind:"missing_credentials"`, in under 200ms (no network attempt). **Why this matters:** `prompt` is the primary consumer entry point. Auth-absent failure routing to stderr breaks every automation wrapper that captures `$(claw prompt ... --output-format json)`. The `doctor` preflight metadata gap means auth-readiness checks require parsing 5 legacy fields instead of reading one boolean. Cross-references #447 (all JSON error envelopes on stderr), #449 (session list hits auth gate), #431 (skills uninstall hits auth gate), #357 (auth gate on local ops cluster), #422 (exit-code parity). Source: Jobdori live dogfood, `a35ee9a0`, 2026-05-16. + +461. **`.claw.json` / `.claw/settings.json` unknown-key validator uses pure-edit-distance ranking with threshold ≤3, which actively misleads users away from the canonical `mcpServers` key — `{ "mcp": { "servers": {} } }` (the VS Code MCP convention, also used by hermes_cli and many other tools) produces `unknown key "mcp" (line 2). Did you mean "env"?`. Edit-distance(`mcp`, `mcpServers`) = 7 (exceeds threshold), edit-distance(`mcp`, `env`) = 3 (at threshold), so the validator hands the user a "go configure environment variables" hint instead of "you wrote the VS Code-style nested form, write the flat `mcpServers` form instead." User follows the suggestion → no MCP servers configured → no warning that MCP was their actual intent → silent loss of functionality with an actively wrong remediation path** — dogfooded 2026-05-24 for the 12:00 Clawhip pinpoint nudge at message `1508077133459751073` (also covers the 11:30 nudge `1508069585461706783`), reproduced on local `./rust/target/debug/claw` `git_sha 003b739d` (origin/main `f8e1bb72`). Suggestion-quality matrix in clean isolated env (`HOME=/tmp/iso15/home`, fresh `/tmp/iso15/proj` git-init, `.claw/settings.json` with one top-level typo each): + + | Input | Edit distance to `mcpServers` | Edit distance to `env` | Actual suggestion | Correct? | + |---|---|---|---|---| + | `mcp` (VS Code nested form) | 7 | **3** | `env` | ❌ **opposite intent** | + | `mcpServer` (singular camelCase) | 1 | 7 | `mcpServers` | ✅ | + | `mcpserver` (singular lowercase) | 2 | 6 | `mcpServers` | ✅ | + | `mcpServrs` (typo) | 1 | 8 | `mcpServers` | ✅ | + | `MCPServers` (case variant) | 3 | 8 | `mcpServers` | ✅ | + | `mcp_servers` (snake_case) | 2 | 7 | `mcpServers` | ✅ | + | `servers` (just servers) | 3 | 5 | `mcpServers` | ✅ | + | `mcp` ← **the actual VS Code convention** | 7 | 3 | `env` | ❌ | + + **Root cause (traced):** `rust/crates/runtime/src/config_validate.rs:396-407`: + + ```rust + fn suggest_field(input: &str, candidates: &[&str]) -> Option { + let input_lower = input.to_ascii_lowercase(); + candidates + .iter() + .filter_map(|candidate| { + let distance = simple_edit_distance(&input_lower, &candidate.to_ascii_lowercase()); + (distance <= 3).then_some((distance, *candidate)) + }) + .min_by_key(|(distance, _)| *distance) + .map(|(_, name)| name.to_string()) + } + ``` + + Pure Levenshtein, threshold-3 filter, no prefix awareness, no semantic awareness. The candidate set is the 14 top-level field names registered in `FIELD_SPECS` (`config_validate.rs:144-200`): `$schema`, `model`, `hooks`, `permissions`, `permissionMode`, `mcpServers`, `oauth`, `enabledPlugins`, `plugins`, `sandbox`, `env`, `aliases`, `providerFallbacks`, `trustedRoots`. For input `"mcp"` (length 3): + + - distance to `mcpServers` = 7 (insertions of `Servers`) — filtered out by threshold + - distance to `env` = 3 (full substitution) — at threshold, wins by `.min_by_key()` + - distance to `oauth`/`model`/`hooks` = 5 — filtered out + - distance to `$schema` = 6 — filtered out + + So `env` is structurally the ONLY survivor of the threshold for short inputs that happen to share zero characters with their actual intended target. **`mcp` is a 3-character prefix of `mcpServers` and shares 100% of its characters with the intended key**, but edit-distance has no way to express that — every additional character in the longer candidate counts as a +1 insertion penalty. **Why distinct from existing items:** ROADMAP #110 covers `ConfigLoader::discover` ancestor-walk under-discovery (config files invisible from subdirs). ROADMAP #28 covers `MissingCredentials` error-copy improvements (adjacent-provider env-var hints). ROADMAP #108 covers CLI subcommand typo fallthrough (no levenshtein for subcommands). **None** address the config-key validator's suggestion-ranking algorithm itself producing actively wrong suggestions for prefix-substring inputs. The "unknown key" diagnostic was added to be helpful — but for the most common real-world miss (`mcp` from users following VS Code convention or from MCP docs that use nested form), it points at the wrong remediation. This is **worse than no suggestion** because users following a bad suggestion lose more time than users getting "unknown key" with no hint and going to read the docs. **Why this matters:** (1) **The VS Code MCP convention is widespread.** VS Code's settings.json uses `"mcp": { "servers": { ... } }`. Cursor uses `mcpServers` flat. Claude Desktop uses `mcpServers` flat. A user who has been configuring MCP servers in VS Code naturally writes the nested form. claw advertises MCP support ("Claude Code parity") but rejects the most-common convention with an **actively misleading remediation**. (2) **Hermes CLI** (in `external/hermes-agent/hermes_cli/config.py:478`) uses `"mcp": { ... }` nested. Any user copying from hermes config will hit this. (3) **`env` is the absolute worst possible suggestion** — it's a completely different concept (environment variables for the CLI) with zero overlap with MCP server registration. A user who follows the suggestion will add an `env` block, fail to register their server, and have no signal that MCP was their actual intent. (4) **Silent functionality loss:** the MCP-related work the user intended is silently dropped (configured_servers: 0 in `mcp list`). No `doctor` check warns "your config has an `mcp` block that probably should have been `mcpServers`." (5) **The bug class is general** — any short input that is a strict prefix of a long canonical key, where the long key edit-distance exceeds 3, will fall through to whatever happens to be within edit-distance-3 of the input. Edge cases for other current top-level keys: `auth` → suggests `env` (distance 3, vs `oauth` distance 1 — but `oauth` wins because distance 1, OK by luck); `plugin` → distance 1 to `plugins`, fine; `perms` → distance 6 to `permissions`, no suggestion. Future schema additions could create more `mcp`-like edge cases silently. (6) **The `--help` text and docs say `.claw/settings.json` accepts MCP config**, and any user reading the JSON Schema for `mcpServers` and naturally writing the nested form gets pointed at `env`. Documentation/validator divergence. **Required fix shape:** (a) **Add prefix-match as a higher-priority signal than edit-distance.** In `suggest_field`, before computing edit-distance, check if `input` is a prefix of any candidate (case-insensitive) AND that candidate's length ≤ `input.len() * 4` (sanity bound). If yes, return that candidate immediately. Specifically: `if input.len() >= 2 && input is a strict prefix of candidate`, that candidate gets priority over edit-distance matches. This makes `mcp` → `mcpServers` (prefix match wins) instead of `mcp` → `env` (edit-distance match wins). (b) **Add nested-key awareness for known scoped patterns.** When the unknown key is the parent of a known-nested form (e.g. `mcp.servers` would be valid under VS Code convention), emit a specific diagnostic: `"unknown key 'mcp' — claw uses the flat camelCase form 'mcpServers' instead of the VS Code-style 'mcp.servers' nested block. Rewrite as: { \"mcpServers\": { ... } }"`. Hardcode this for `mcp` initially; generalize if other nested-vs-flat divergences appear. (c) **Add a `claw doctor` check `mcp_config_form_drift`** that detects `mcp` keys at the top level of `.claw.json` / `.claw/settings.json` and warns: `WARN: .claw/settings.json contains a top-level "mcp" block (VS Code convention). claw uses "mcpServers" (flat). Migrate to: { "mcpServers": }`. (d) **Update README and `--help`** to document the canonical `mcpServers` key and explicitly call out that `mcp.servers` (VS Code style) is NOT accepted. (e) **Regression coverage:** add a test for `suggest_field("mcp", FIELD_SPECS)` that asserts the result is `Some("mcpServers")` (not `Some("env")`). Add the full suggestion matrix above as parameterized tests. **Acceptance check (one-liner):** `cd /tmp/test && mkdir -p .claw && echo '{"mcp":{"servers":{"a":{"command":"x"}}}}' > .claw/settings.json && claw mcp list --output-format json | jq -r '.config_load_error' | grep -E '"mcpServers"|VS Code|migrate'` should match (it currently outputs `Did you mean "env"?`). Source: gaebal-gajae dogfood follow-up spanning two consecutive Clawhip pinpoint nudges (2026-05-24 11:30 message `1508069585461706783` triggered the MCP investigation; 12:00 message `1508077133459751073` triggered finishing it; matrix completed and root-cause traced between the two).