edit_file on a UTF-8 file carrying CJK text rewrote bytes it never touched:
continuation bytes of characters far from the edit came back as 84 31 A4 37.
Detect took a nil error from the GB18030 decoder as proof the file was
GB18030. The decoder never fails: it maps every invalid sequence to U+FFFD.
So any file that was not valid UTF-8 -- one stray byte is enough -- was read
as GB18030, and the read-modify-write tools (edit_file, multi_edit,
delete_range, write_file over an existing file), checkpoint restore and the
config migrations then re-encoded every unreadable sequence as the GB18030
form of U+FFFD.
Detect now names a charset only when decoding and re-encoding restores the
input byte for byte. GB18030 is tried first, then GBK, which restores what
GB18030 cannot such as CP936's single-byte euro 0x80, so such a file stays
editable in its own encoding; anything else is LossyUTF8, whose bytes pass
through untouched. The new Kind is appended because checkpoints persist it
by value.
GBK cannot hold every character, so Encode now returns an error instead of
falling back to UTF-8, which rewrote a whole CP936 file as UTF-8 when an edit
added a character such as an emoji. A legacy charset that cannot represent a
character answers an *UnencodableError naming it, its byte offset and the
charset, with ErrUnencodable as its identity; the file tools, checkpoint
restore and the config migrations refuse the write and leave the file as it
was. Fixtures encode through MustEncode.
Readers that hold only part of a stream say so rather than inherit the
whole-file answer:
- DetectFragment runs the round trip on the prefix the decoder consumes when
not at EOF, so a character the cut split is dropped structurally. grep's
8 KiB peek, read_file's 256 KiB sample and the desktop's 2 MiB file preview
use it; the preview's next page starts at the split character, and lossy
text is shown as text rather than as binary.
- DecodeOutput is the one entry point for bytes that are only displayed.
The producer says which ends its bound cut (Cut{Head, Tail}); UTF-8 is read
without a character split at a cut end, and an uncut end keeps its bytes,
since a GBK pair there is output rather than half a rune. What no charset
restores is still read as GB18030. Hook output passes its tail cut when
truncated.
- DetectAndDecode decodes a legacy charset once; the edit tools and
DecodeToUTF8 use it instead of detecting and decoding separately.
Tests that fail before this change (on the base or an earlier revision of it)
and pass here:
- TestE2EEditKeepsBytesOfUTF8FileWithStrayByte: e4 b8 ad became
e4 b8 84 31 a4 37 outside the edit (fails on origin/main-v2).
- TestDetectUTF8WithStrayByteIsNotGB18030 (fails on origin/main-v2).
- TestWritesRefuseCharacterCP936CannotHold (edit_file, multi_edit,
write_file): nil error and the whole CP936 file rewritten as UTF-8.
- TestDecodeOutputTrimsCutUTF8Edges: UTF-8 cut at the front read as mojibake.
- TestDecodeOutputKeepsUncutCodePageEdges, TestDecodeHookOutputKeepsUncutCodePageEdges:
GBK pairs at an uncut edge (bc db, b0 a1 0d 0a, 6f 6b 20 e4 a1,
b0 a1 20 6f 6b) were dropped as partial UTF-8 runes.
- TestEditKeepsCP936FileWithEuroByte: an edit adding Chinese wrote UTF-8
into a CP936 file.
- TestGrepGB18030PeekWithoutNewline, TestGrepGB18030PeekCutMidCharacter:
no matches past a peek cut mid-character.
- TestReadFileGB18030SampleWithoutNewline: garbled text past the sample.
- TestReadFileGB18030TruncatedWithoutNewline,
TestReadFileGB18030TruncatedMidCharacter (desktop): the 2 MiB preview of a
GB18030 file was garbled or ended in U+FFFD.
- TestDecodeHookOutputReadsTruncatedCodePageText,
TestDecodeHookOutputReadsCP936Euro: mojibake for cut or euro-carrying cmd.exe output.
- TestEncodeRefusesCharacterTheCharsetCannotHold, TestDetectGBKWithEuroByte,
TestDetectFragmentDropsTheSplitCharacter, TestDecodeOutputReadsCutCodePageText:
the API's contract.
Problem: Reasonix synthesized a SessionStart context hook from a Claude plugin root CLAUDE.md, although Claude Code does not load that file as plugin project context.
Root cause: Claude manifests shared the Codex compatibility helper that always appended root instructions.
Fix: keep supported Claude convention imports without the synthetic root hook, document the distinction, and update install-source fixtures to use official skills.
Verification: go test ./...; ./scripts/cache-guard.sh; focused plugin and install-source tests; desktop plugin/theme tests.
* Fix plugin hook execution contracts
Problem: Windows plugin hooks with compound commands or nested quoting could still fail after the narrow quoted-batch compatibility fix, while explicit empty args could lose exec-form semantics across JSON round trips.
Root cause: Hook launch behavior was inferred from argument length and platform shell heuristics instead of preserving the manifest's exec-form or shell-form contract. CMD and PowerShell command strings also passed through incompatible quoting layers.
Fix: Preserve args field presence and shell selection through plugin parsing and loading, add explicit legacy/exec/shell execution modes, transport raw CMD and encoded PowerShell scripts without token re-rendering, retain the existing simple batch compatibility path, and document the cross-platform contract.
Verification: Focused Hook and plugin package tests, race and shuffled tests, fuzzing, root and Desktop full suites, go vet, and Windows/Linux cross-compilation.
* Fix Windows hook output regressions
Problem: The new compound CMD regression expected cmd.exe to strip quotes from %1, leaving the Windows job red. PowerShell hook scripts also preserved UTF-16LE input transport without forcing captured output to UTF-8.
Root cause: The test confused %1 with the dequoting %~1 form, and the Hook-specific EncodedCommand path did not reuse the shell runtime's existing PowerShell output-encoding prologue.
Fix: Align the CMD assertion with native batch semantics, share the PowerShell UTF-8 script wrapper across shell and Hook launchers, and exercise Chinese plus emoji output in the Windows runtime test.
Verification: Focused Hook/plugin/sandbox tests and vet; race/shuffle count 3; root and Desktop full suites; full vet; Windows amd64 test cross-compilation; PowerShell encoding fuzzing.
* fix(mcp): trust installed readers in subagents
Problem:
User-installed MCP tools that declared readOnlyHint could be exposed as readers but then fail before dispatch in planners and read-only subagents. Users were told to configure a second per-tool reader policy after already installing or authorizing the server.
Root cause:
Strict reader trust did not treat explicit install or an exact project launch grant as authority for non-destructive server metadata. Direct, lazy, and on-demand paths also duplicated cached-to-live safety reconciliation, while trusted_read_only_tools remained wired through configuration and Desktop contracts.
Fix:
Authorize non-destructive readOnlyHint tools for installed or launch-authorized servers, carry reader intent only for trusted readers, and share one cached-to-live safety reconciliation boundary across lazy and on-demand adapters. Remove trusted_read_only_tools from runtime, Desktop, examples, and docs while accepting and dropping legacy input on the next save.
Verification:
- env -u DEEPSEEK_API_KEY go test -count=1 ./...
- cd desktop && go test -count=1 ./...
- cd desktop/frontend && pnpm test:typecheck
- cd desktop/frontend && pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts
- ./scripts/cache-guard.sh
- ./scripts/check-cache-impact.sh
- git diff --check
* refactor(mcp): simplify installed server authorization
Problem:
User-installed MCP servers still depended on official-catalog and per-tool reader trust layers, so direct, lazy, and on-demand paths could disagree and strict child sessions could reject an already authorized reader.
Root cause:
Server authorization, signed package status, cached reader trust, and legacy plan-mode allowlists were modeled as separate overlapping decisions.
Fix:
Remove the official MCP catalog, signature/revocation plumbing, package verification, per-tool cached trust, and obsolete reader settings. Use one server-level authorization source for user installs, host-session installs, and exact project grants while retaining live read-only, destructive, and capability-fingerprint checks.
Verification:
- go test ./internal/plugin ./internal/agent ./internal/boot ./internal/config ./internal/installsource ./internal/pluginpkg ./internal/mcplaunch ./internal/planmode
- go test ./internal/acp ./internal/agent ./internal/plugin ./internal/boot
- cd desktop && go test ./...
- ./scripts/cache-guard.sh
- git diff --check
* refactor(mcp): make install authorization final
Problem:
Installed MCP servers could still encounter server, per-tool, writer, or destructive approval branches, and users had to understand settings that duplicated the install decision.
Root cause:
MCP authorization, cached reader trust, approval policy, and process sandboxing evolved as separate paths across direct, lazy, and on-demand execution.
Fix:
Treat install or exact project identity confirmation as the server authorization boundary, keep only explicit global deny plus Plan/read-only and sandbox safety checks, remove catalog and approval configuration, and unify the persistent MCP process sandbox.
Verification:
env -u DEEPSEEK_API_KEY go test ./...
go build ./...
cd desktop && go test ./...
cd desktop/frontend && pnpm typecheck
cd desktop/frontend && pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts
cd desktop/frontend && pnpm exec tsx src/__tests__/decision-surface.test.tsx
* chore(mcp): remove retired cache helper
Problem:
CI lint found writeBoolKV left unused after per-tool reader trust was removed.
Root cause:
The cached reader-map fingerprint was deleted, but its serialization helper remained.
Fix:
Remove the dead helper.
Verification:
go test ./internal/plugin
go vet ./internal/plugin
go build ./...
* refactor(mcp): stop blocking schema-only drift
Problem:
Authorized MCP calls could be rejected when a cached tool schema differed from the live server even though authorization and safety classification were unchanged.
Root cause:
A capability fingerprint mixed schema compatibility with read-only and destructive execution safety across direct, lazy, and on-demand paths.
Fix:
Remove the tool-level schema fingerprint chain, keep live server authorization and read-only/destructive reclassification checks, and refresh changed schemas for the next session without interrupting the current authorized call.
Verification:
- go test ./internal/plugin ./internal/agent ./internal/tool
- env -u DEEPSEEK_API_KEY go test ./...
- go vet ./...
- go build ./...
- ./scripts/cache-guard.sh
- pnpm --dir site test
- cd desktop && go test ./...
* fix(mcp): preserve short Windows temp paths
Problem:
Windows stdio MCP servers inherited a deeply nested Reasonix private TEMP path. Servers such as MATLAB create watchdog Unix sockets below TEMP and exit before initialization when the path exceeds the platform limit.
Root cause:
MCP private-state preparation redirected TMP, TEMP, and TMPDIR below the workspace-scoped mcp-state directory on every platform even though Reasonix does not currently provide an OS sandbox on Windows.
Fix:
Keep the host temporary-directory environment for Windows stdio MCP processes while retaining private cache and state directories. Preserve the existing private temporary directory behavior on other platforms and add focused regression coverage for both branches.
Verification:
- go test ./internal/plugin -count=1
- GOOS=windows GOARCH=amd64 go test -c ./internal/plugin
- go test ./...
- go vet ./internal/plugin
- git diff --check
* Simplify MCP runtime authorization state
Use Spec.Authorized as the sole resolved server authorization result so installed and host-session MCP servers no longer depend on launch-manager presence.
Keep exact identity grants for project sources, align planner and strict read-only registries with non-destructive readOnlyHint metadata, and document the cooperative trust boundary.
Add source and agent-surface matrix coverage while preserving the main provider-visible MCP tool surface.
* Limit MCP identity digests to project servers
Problem: User-installed and host-session MCP servers still resolved and hashed their executable identity during every startup even though installation was already the authorization decision.
Root cause: Project launch authorization and schema-cache ownership shared generic fingerprint terminology, and start() computed the project identity before checking whether launch approval was required.
Fix: Skip project identity resolution for installed servers, remove dead and non-participating identity state, and rename the remaining concepts to ProjectLaunchIdentityDigest and SchemaCacheKey while preserving existing JSON keys and digest bytes.
Verification: go test ./internal/mcplaunch ./internal/plugin ./internal/capability ./internal/boot ./internal/control ./internal/agent; go test ./...; cd desktop && go test .; go vet ./...; ./scripts/cache-guard.sh; git diff --check.
* Restore MCP 0.53 compatibility surfaces
Problem: MCP authorization was simplified, but the current client still lacked four compatibility surfaces present in the 0.53 line: legacy SSE, workspace roots, progress notifications, and registry browsing/install.
Root cause: the Go transport abstraction dropped inbound notifications and rejected legacy SSE; runtime specs did not carry a separate workspace root; the registry UI and CLI had no official MCP Registry client.
Fix: add inbound request/progress routing across transports, implement legacy SSE with same-origin endpoint protection, advertise roots/list, and add an explicit cached official Registry marketplace to CLI and desktop. Remove stale approval documentation and the dead helper that broke lint.
Verification: go test ./...; focused go test -race for plugin/registry/CLI/control; go vet in root and desktop; desktop go test ./...; frontend production build and marketplace UI test; Windows amd64 cross-build; official Registry browse smoke test.
* Avoid MCP progress capacity overflow
* Align MCP authorization documentation
Problem: CLI help and Chinese documentation still contained ambiguous or obsolete wording that implied installed MCP writer or destructive calls could require per-call approval.
Root cause: The server-level authorization simplification removed MCP approval modes, but not every user-facing explanation was updated consistently.
Fix: State explicitly that install or exact project identity confirmation authorizes the server, that writer and destructive hints do not prompt per call, and that deny, Plan, and strict read-only filtering remain separate boundaries.
Verification: go test ./internal/cli; go vet ./internal/cli; reasonix mcp help smoke test; stale-wording scan; git diff --check.
* Harden MCP registry installs and SSE replies
Problem:
Registry cache freshness was shared across queries, cached entries could flow into install paths, and legacy SSE server requests spawned an unbounded reply goroutine per request.
Root cause:
The cache stored one global timestamp, Resolve reused Search's offline fallback, the desktop installed browse payloads directly, and the SSE reader delegated every reply to a new goroutine.
Fix:
Track freshness per Registry query with legacy-cache compatibility, require live resolution before CLI and desktop installs, and serialize SSE replies through a bounded single-worker queue with finite deadlines.
Verification:
- env -u DEEPSEEK_API_KEY go test -count=1 ./...
- env -u DEEPSEEK_API_KEY go test -race -count=1 ./internal/mcpregistry ./internal/plugin
- env -u DEEPSEEK_API_KEY go test -count=1 ./... (desktop)
- env -u DEEPSEEK_API_KEY go vet ./... (root and desktop)
- pnpm run test:all
- pnpm run build
- env -u DEEPSEEK_API_KEY ./scripts/cache-guard.sh
- git diff --check
* Reject unavailable live registry installs
Problem:
A Registry entry could become non-installable between browsing and the desktop's live re-resolution, leaving the generic add flow with an invalid empty connection. The SSE concurrency regression also used a fixed delay after routing the client response.
Root cause:
The desktop resolver verified freshness but not the resolved entry's installability, and the test inferred worker startup from elapsed time.
Fix:
Validate the live entry through PluginEntry before returning it to the installer, and synchronize the SSE test on the first reply POST instead of sleeping.
Verification:
- env -u DEEPSEEK_API_KEY go test -count=5 ./internal/plugin
- env -u DEEPSEEK_API_KEY go test -race -count=3 ./internal/plugin
- env -u DEEPSEEK_API_KEY go test -count=1 ./...
- cd desktop && env -u DEEPSEEK_API_KEY go test -count=1 ./...
- git diff --check
- An unbounded Reasonix wait now omits TaskOutput's optional timeout
instead of claiming 0 — in Claude's schema timeout is the maximum wait
in ms, so 0 would read as "don't wait", the opposite of the call.
- Structural input gaps (WebFetch prompt, NotebookEdit cell_number,
TaskOutput multi-job) are reported once per hooks file instead of once
per hook item, so a wildcard-matcher plugin gets one warning per gap
rather than one per hook; file-level wording also keeps the emitted
set deterministic across the random event-map iteration order.
- Real-spawn hook tests share one generous realSpawnTimeout: node cold
starts and first-run scans of a freshly built test binary can stall
for seconds on a loaded machine, and these tests assert behavior, not
latency. The timeout-path case keeps its tight budget on purpose.
Fourth review round on #6434 found two P2 accuracy gaps once the two P1
security bypasses were confirmed fixed:
- claude_compat.go accepted a hook's matcher without checking whether it
names a real Claude tool Reasonix has no equivalent for. "WebSearch"
(no Reasonix web-search tool) and "ExitPlanMode"/"EnterPlanMode" (plan
approval is a controller-level decision, never dispatched through the
tool-call hook path; confirmed by tracing permissionRequestHookPayload's
explicit exclusion of planApprovalTool) are two concrete, independently
verified examples: a hook scoped to only these matchers would never fire
while the package still reported "full" compatibility. A plain
tool-name matcher, or a "|"-alternation of them, naming only tools from a
conservative, individually-verified unsupported set now downgrades the
package to "partial" with a structured warning; matchers using other
regex syntax, or naming even one tool Reasonix does support, are left
unevaluated to avoid false positives. Also fixed a real gap found while
auditing this: "run_skill" (Reasonix's skill-invocation tool) was never
mapped to Claude's "Skill" tool name, so a "Skill"-scoped hook silently
never fired either — same bug shape as the task/ask fixes from the
previous round.
- Added a regression test exercising the opts.fresh-only branch of the
fresh-human approval fix (PlanModeReadOnlyCommandApprovalTool, which is
fresh via requestFreshApprovalDecision without being one of
RequiresFreshHumanApprovalTool's fixed cases) — the existing tests only
covered tools protected through requiresFreshApprovalTool(tool).
Co-authored-by: SivanCola <[email protected]>
Second review round on #6434 found two more hook security gaps and an
over-claimed compatibility status:
- imported Claude hook matchers (e.g. "Bash", "Write|Edit") and the
tool_name field a hook script inspects were left in Reasonix's own
vocabulary (bash, write_file, ...), so a matcher-scoped or tool_name-gated
Claude hook silently never fired. Added a name-translation layer
(claudeFacingToolName) applied only to PayloadFormat "claude" hooks, both
at match time and in the emitted payload.
- UserPromptSubmit's standard exit-0 JSON block (top-level
{"decision":"block","reason":...}, distinct from PreToolUse/
PermissionRequest's hookSpecificOutput shape) was not parsed, so a hook
denying a sensitive prompt via JSON instead of exit 2 was ignored.
- PermissionRequest's "allow" decision (auto-approve without prompting) was
never implemented — only the deny half of Claude's auto-answer contract
from the prior fix. Runner.PermissionRequest now returns a tri-state
decision (nil/deny/allow) and the approval flow honors all three.
- claude_compat.go now parses the "if" and "asyncRewake" hook fields and a
Stop/SubagentStop hook import, and downgrades compatibility to "partial"
with a structured warning instead of claiming "full" for semantics
Reasonix doesn't evaluate (conditional execution, wake-on-exit-2 async,
and block-the-turn respectively). The hook is still imported best-effort.
An existing test (TestPlanClaudeCompatibilityReportsAgentsHooksAndMCP,
modeled on the PR's own agent-critter example) had asserted "full"
compatibility for exactly this Stop-hook case; updated to the corrected
"partial" expectation.
Co-authored-by: SivanCola <[email protected]>
Review on #6434 found that an imported Claude PreToolUse/PermissionRequest
hook's deny decision was silently dropped: ParseOutput only ever extracted
additionalContext from hook stdout, and PermissionRequest's exit code 2 was
explicitly downgraded to a notification instead of a block. A Claude plugin
that relies on hooks to refuse dangerous commands or permissions would show
as fully compatible while the tool call still ran.
- honor Claude's JSON deny decision (hookSpecificOutput.permissionDecision
for PreToolUse, hookSpecificOutput.decision.behavior for PermissionRequest)
even on exit 0, and have exit code 2 block an imported PermissionRequest
hook the same way Claude's own contract does. Scoped to PayloadFormat
"claude" so native Reasonix hook semantics (PermissionRequest stays
advisory-only) are unchanged.
- wire the PermissionRequest hook synchronously into the approval flow so a
deny preempts the prompt instead of only notifying after the fact.
- fix hook dedup (appendUniqueHook) to also compare Env, Timeout, Async, and
Cwd, so two hooks that share a command/matcher but differ in one of those
no longer collapse into one.
- fix Windows path-separator mismatch: ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PROJECT_DIR}
expansion left a mixed "C:\...\pkg/bin/server" string since the manifest's
"/"-authored suffix was never converted to the host separator.
- lint: tagged switch (QF1003) and remove two functions orphaned by this PR's
own refactor (appendClaudeSettingsHooks, pluginNameExists).
Co-authored-by: SivanCola <[email protected]>
Round-3 review follow-ups on the commands mapping:
- P1: plan and apply each cloned the source separately, so a repo that
moved between the approved plan and the apply resolution could install
content the approval never covered (and the optional planId echo could
not catch the apply call's own second clone). pluginSource now reports
the resolved commit for git sources; the plan records it on the action
— entering the plan ID, so approvals fingerprint the exact snapshot —
and apply pins a diverged clone back to the approved commit via a
full-SHA fetch + detached checkout, refusing with a re-plan error when
the snapshot is no longer reachable. Regression covers the unpinnable
drift refusal end to end.
- P2: the command-dir adoption gate capped recursion at 5 levels while
the runtime loader walks unbounded, so a plugin whose only command sat
deeper was never adopted. The gate now delegates to internal/command
itself — adoption and runtime discovery share one implementation and
cannot diverge; a six-level nesting regression pins it.
Review follow-ups on the commands mapping:
- P1: planGitHubPluginPackage assembled a sparse temp dir holding only
the fetched manifest, so convention-discovered capabilities (skills/,
commands/) and their warnings were invisible at plan time while apply
cloned the full repo — the approved plan under-described what actually
got installed. Plan and apply now resolve the source through one
shared pluginSource resolver (shallow clone for GitHub), making the
approval contract hold by construction; a plan/apply consistency
regression covers it.
- P2: dirContainsCommandMd only looked at the top level, so a plugin
whose commands all live in namespace subdirs (commands/git/commit.md)
was never adopted even though the runtime loader walks them. The
adoption gate now recurses (bounded depth, hidden dirs skipped).
- P3: appendInventoryText's empty-inventory fallback ignored commands, so
a commands-only plugin printed its command list followed by
"capabilities: no detailed inventory available".
Verified: reviewer's repro now plans skills=6 commands=11 for
github.com/OthmanAdi/planning-with-files with zero warnings.
Claude plugins like planning-with-files ship their primary interface as
commands/ (flat <name>.md prompt templates invoked as /<name>), which
Reasonix previously dropped with a "does not map yet" warning — the
plugin installed but /plan, /pwf etc. never appeared, reading to users
as a failed install (discussion #5762).
A Claude command is exactly Reasonix's custom slash command shape
(internal/command: frontmatter description/argument-hint, $ARGUMENTS /
$1..$N substitution, subdir namespacing), so map the capability instead
of warning about it:
- pluginpkg: Manifest gains Commands (explicit "commands" list in any
manifest kind; claude plugins adopt conventional commands/ and
.claude/commands/ automatically, even alongside explicit skills).
Inventory/CapabilityCounts/describe now report commands, loaded via
internal/command so names can never drift from runtime behavior.
- config: CommandDirsForRoot prepends enabled plugin packages' command
roots at the lowest priority — boot and hot ReloadCommands both pick
them up, and a user- or project-authored command wins name clashes.
- cli/installsource: plugin verify checks command roots; install plans
carry commands/commandCount; plugin show lists commands.
- desktop: plugin view exposes commands count + details; capabilities
panel renders a Commands section (en/zh/zh-TW strings).
- docs: EN/zh-CN plugin docs describe the mapping; commands removed
from the unmapped-capabilities warning list (agents, hooks/hooks.json,
.mcp.json still warn).
Verified end-to-end against planning-with-files v3.4.0: installs with
zero warnings, 6 skills + 11 commands, /plan invocable.
Review findings on the Claude manifest support:
- A standard Claude plugin ships a metadata-only plugin.json and relies
on Claude's auto-discovery of the conventional skills/ directory (packs
in the wild also use .claude/skills/). Reading only the explicit
`skills` field installed such packages as zero capabilities with no
warning — "install succeeded" while nothing usable landed. Claude-kind
parsing now falls back to the conventional directories when the
manifest declares none, adopting a directory only if it actually holds
a <name>/SKILL.md. Explicit declarations disable the fallback; codex
parsing is untouched.
- Conventional Claude capabilities Reasonix does not map yet (commands/,
agents/, hooks/hooks.json, .mcp.json) now surface as install warnings
instead of being silently dropped.
- Negative test pins the includeCodexSessionStartHook boundary: a
claude-kind package shipping hooks/session-start-codex must not get it
registered as an executable SessionStart hook.
- Docs: retitle the compatibility section to "Codex & Claude", document
the fallback, the warnings, and that marketplace.json indexes are out
of scope.
Fallback reverse-verified: removing the convention-dir call fails
TestParseClaudePluginConventionSkillDirs.