From 60ed337014f9e1d2d0420218b0c69dcc2743d5a7 Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Wed, 8 Jul 2026 22:07:54 -0700 Subject: [PATCH 1/2] feat(reasonix): add rules + skills adapters, expand hook events, MCP timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow up DeepSeek-Reasonix upstream updates (all four verified against primary sources): - rules: new ReasonixRule adapter emitting the vendor REASONIX.md at the project root (project) / ~/.reasonix/REASONIX.md (global), folding non-root rules into the single root file (mirrors codexcli). - skills: new ReasonixSkill adapter for Anthropic-style directory-layout skills under .reasonix/skills//SKILL.md (project + global), modeling the portable name/description frontmatter. - hooks: map four more upstream events onto their canonical equivalents (sessionStart/sessionEnd/subagentStop, and postModelInvocation ⇄ PostLLMCall), taking Reasonix from four to eight mapped events. Notification/PreCompact have no canonical event and are left out; the optional cwd hook field is deferred (needs a canonical HookDefinition field decision). - mcp: pass through the Reasonix-only [[plugins]] call_timeout_seconds (per-server) and tool_timeout_seconds (per-tool table) fields on round-trip. Wires the new adapters into the tuples/processors, regenerates the supported-tools tables and gitignore, syncs docs, and adds unit + e2e Tool x Feature coverage for reasonix rules and skills. Closes #2157 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 2 + README.md | 2 +- docs/reference/file-formats.md | 8 +- docs/reference/supported-tools.md | 2 +- skills/rulesync/file-formats.md | 8 +- skills/rulesync/supported-tools.md | 2 +- src/constants/reasonix-paths.ts | 12 + src/e2e/e2e-hooks.spec.ts | 11 +- src/e2e/e2e-rules.spec.ts | 2 + src/e2e/e2e-skills.spec.ts | 9 + src/features/hooks/reasonix-hooks.test.ts | 16 +- src/features/hooks/reasonix-hooks.ts | 5 +- src/features/mcp/reasonix-mcp.ts | 6 + src/features/rules/reasonix-rule.test.ts | 107 +++++++++ src/features/rules/reasonix-rule.ts | 135 ++++++++++++ src/features/rules/rules-processor.test.ts | 4 +- src/features/rules/rules-processor.ts | 16 ++ src/features/skills/reasonix-skill.test.ts | 97 +++++++++ src/features/skills/reasonix-skill.ts | 217 +++++++++++++++++++ src/features/skills/skills-processor.test.ts | 5 + src/features/skills/skills-processor.ts | 11 + src/types/hooks.ts | 23 +- src/types/tool-target-tuples.ts | 2 + src/types/tool-targets.test.ts | 2 +- 24 files changed, 681 insertions(+), 23 deletions(-) create mode 100644 src/features/rules/reasonix-rule.test.ts create mode 100644 src/features/rules/reasonix-rule.ts create mode 100644 src/features/skills/reasonix-skill.test.ts create mode 100644 src/features/skills/reasonix-skill.ts diff --git a/.gitignore b/.gitignore index 124d86674..c1f11ab12 100644 --- a/.gitignore +++ b/.gitignore @@ -269,6 +269,7 @@ rulesync.local.jsonc **/.opencode/memories/ **/QWEN.md **/.qwen/rules/ +**/REASONIX.md **/replit.md **/.roo/rules/ **/.rovodev/AGENTS.md @@ -311,6 +312,7 @@ rulesync.local.jsonc **/.opencode/skills/ **/.pi/skills/ **/.qwen/skills/ +**/.reasonix/skills/ **/.roo/skills/ **/.rovodev/skills/ **/.takt/facets/knowledge/ diff --git a/README.md b/README.md index 827a28374..9c2453fa9 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ The tables below show whether each tool supports a given feature (✅ = supporte | Takt | ✅ | | ✅ | ✅ | ✅ | ✅ | | ✅ | | Vibe Code | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | | Qwen Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Reasonix | | | ✅ | ✅ | | | ✅ | ✅ | +| Reasonix | ✅ | | ✅ | ✅ | | ✅ | ✅ | ✅ | | Kiro ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Kiro CLI | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Kiro IDE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index bc1e5bb03..4db739443 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -68,6 +68,8 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration > **Pi note:** Pi writes the root rule to the auto-loaded `AGENTS.md` (project) / `~/.pi/agent/AGENTS.md` (global, via `--global`) as plain Markdown, and folds non-root rules into that single file (Pi has no modular rules directory). Pi additionally supports two system-prompt instruction files that Rulesync does **not** currently emit: `.pi/SYSTEM.md` (project) / `~/.pi/agent/SYSTEM.md` (global) **replaces** the default system prompt entirely, and `.pi/APPEND_SYSTEM.md` (project) / `~/.pi/agent/APPEND_SYSTEM.md` (global) **appends** to it. Rulesync's rules model only routes a designated `root` rule to a single context file and has no frontmatter convention for marking a rule as "replace the system prompt" versus "append to the system prompt", so these files are left to be authored by hand. See the [Pi usage docs](https://pi.dev/docs/latest/usage). +> **Reasonix note:** Reasonix auto-injects a hierarchical instruction document, reading its vendor-specific `REASONIX.md` (alongside the cross-tool `AGENTS.md`/`CLAUDE.md`) by walking user-home → ancestors → project root/local. Rulesync writes the vendor `REASONIX.md` at the project root (project) / `~/.reasonix/REASONIX.md` (global, via `--global`) and folds non-root rules into that single file, since Reasonix has no modular rules directory. See the [Reasonix GUIDE](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/GUIDE.md). + ## `.rulesync/hooks.json` Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `stop` → `agentStop`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`); Qwen Code emits PascalCase events into the `hooks` key of `.qwen/settings.json` (its supported event set differs from Gemini CLI's). @@ -210,7 +212,7 @@ Events present in the shared `hooks` block but unsupported by a given tool are s > **Note:** Qwen Code hooks are written under the top-level `hooks` key of `.qwen/settings.json` (project) / `~/.qwen/settings.json` (global), using Claude-style PascalCase per-matcher arrays (`{ "EventName": [ { "matcher": "...", "sequential": false, "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Sixteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, `notification` ⇄ `Notification`, `todoCreated` ⇄ `TodoCreated`, and `todoCompleted` ⇄ `TodoCompleted`. Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting). Qwen's four hook types are supported: `command`, `prompt`, `http` (which carries a `url` and POSTs JSON to it; the type and URL round-trip), and `function`. Per-hook fields added in [Qwen Code PR #2827](https://github.com/QwenLM/qwen-code/pull/2827) round-trip as well: command hooks carry `async` (run in the background), `env` (extra subprocess environment variables), and `shell` (`bash`/`powershell`); http hooks carry `headers` (with `${VAR}` interpolation), `allowedEnvVars` (the env-var allowlist), and `once` (single execution per event per session); `statusMessage` (progress text) applies to both. Command-only fields are emitted only on command hooks and http-only fields only on http hooks. The group-level `sequential` flag (parallel by default) and the top-level `disableAllHooks` switch are both round-tripped, and other top-level keys in `settings.json` are preserved. See the [Qwen Code hooks docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md). -> **Note:** Reasonix hooks are written to a dedicated `.reasonix/settings.json` (project) / `~/.reasonix/settings.json` (global) — a Claude-Code-style but standalone JSON file, separate from the `[permissions]`/`[[plugins]]` TOML config. Unlike Claude Code, each event key maps directly to a **flat array** of hook objects (no `matcher`/`hooks` wrapper): `{ "EventName": [ { "match": "...", "command": "...", "description": "...", "timeout": ... } ] }`. Only four of Reasonix's documented events are mapped — `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, and `stop` ⇄ `Stop` — since the upstream issue scoped the rest (`SessionStart`, `SessionEnd`, `PostLLMCall`, `SubagentStop`, `Notification`, `PreCompact`) as a separate follow-up. `match` (Reasonix's matcher field name) is honored only on `PreToolUse`/`PostToolUse`; a matcher on any other event is dropped with a warning. The canonical `timeout` field is documented in seconds, while Reasonix's `timeout` is milliseconds, so rulesync converts (`× 1000` on generate, `÷ 1000` on import). Only `command`-type hooks are supported. The `settings.json` file is not documented as holding anything besides hooks today, but rulesync merges non-destructively and never deletes it, in case a future Reasonix version adds other keys. See the [Reasonix Hooks guide](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/DESKTOP_HOOKS.zh-CN.md). +> **Note:** Reasonix hooks are written to a dedicated `.reasonix/settings.json` (project) / `~/.reasonix/settings.json` (global) — a Claude-Code-style but standalone JSON file, separate from the `[permissions]`/`[[plugins]]` TOML config. Unlike Claude Code, each event key maps directly to a **flat array** of hook objects (no `matcher`/`hooks` wrapper): `{ "EventName": [ { "match": "...", "command": "...", "description": "...", "timeout": ... } ] }`. Eight of Reasonix's ten documented events are mapped — `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `subagentStop` ⇄ `SubagentStop`, and `postModelInvocation` ⇄ `PostLLMCall` — while `Notification` and `PreCompact` have no canonical rulesync event and are left out. `match` (Reasonix's matcher field name) is honored only on `PreToolUse`/`PostToolUse`; a matcher on any other event is dropped with a warning. The canonical `timeout` field is documented in seconds, while Reasonix's `timeout` is milliseconds, so rulesync converts (`× 1000` on generate, `÷ 1000` on import). Only `command`-type hooks are supported. The `settings.json` file is not documented as holding anything besides hooks today, but rulesync merges non-destructively and never deletes it, in case a future Reasonix version adds other keys. See the [Reasonix Hooks guide](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/DESKTOP_HOOKS.zh-CN.md). ## `.github/mcp.json` and `.copilot/mcp-config.json` @@ -571,6 +573,8 @@ When `claudecode.scheduled-task: true` is set, that skill is emitted only as a C > **Note:** Codex CLI reads UI metadata, invocation policy, and tool dependencies from an `agents/openai.yaml` sidecar next to `SKILL.md` (Codex's `SKILL.md` frontmatter only carries `name` and `description`). When `codexcli.interface`, `codexcli.policy`, or `codexcli.dependencies` is present, Rulesync emits `.agents/skills//agents/openai.yaml` and reads it back on import. If the sidecar is emitted and `interface.short_description` is absent, the legacy `codexcli.short-description` is routed there. See the [Codex skills docs](https://developers.openai.com/codex/skills.md). +> **Reasonix note:** Reasonix discovers Anthropic-style directory-layout skills (`/SKILL.md`) under `.reasonix/skills/` (project) / `~/.reasonix/skills/` (global, via `--global`). Rulesync emits the portable `name`/`description` frontmatter (Reasonix supports additional optional keys, but only that pair is modeled); the schema is loose, so any extra keys on an imported `SKILL.md` survive the round-trip. See the [Reasonix GUIDE](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/GUIDE.md). + ## `.rulesync/mcp.json` Example: @@ -724,7 +728,7 @@ Subagents live in the `subagents/` subdirectory so the command-recipe and subage Vibe (mistral-vibe) MCP servers live in `[[mcp_servers]]` arrays of the shared `.vibe/config.toml`. In addition to the flat fields, Rulesync passes through the stdio `cwd` (working directory) and a structured per-server `auth` block (Vibe v2.15.0+). The `auth` table is discriminated on `type`: `static` (`headers`, `api_key_env`, `api_key_header`, `api_key_format`) and `oauth` (`scopes`, `client_id` / `client_metadata_url`, `redirect_port`). Because Vibe rejects mixing legacy top-level static-auth keys with an explicit `[auth]` block, Rulesync suppresses the legacy keys (`headers`/`api_key_env`/`api_key_header`/`api_key_format`) whenever a server carries an `auth` block. See [mistral-vibe](https://github.com/mistralai/mistral-vibe) (`vibe/core/config/_settings.py`). -> **Reasonix note:** MCP servers are written as `[[plugins]]` array-of-tables entries (Reasonix's MCP-compatible external plugins) in `reasonix.toml` (project) / `~/.reasonix/config.toml` (global, via `--global`). Each entry carries a `name` plus the standard transport fields: `type` selects the transport (`stdio` default — `command`/`args`/`env`; `http`, a.k.a. `streamable-http` — `url`/`headers`), and the deprecated `sse` transport is collapsed onto `http`. The file is treated as shared Reasonix config: Rulesync only replaces the `plugins` key and preserves every other table (providers, ui, agent, …) on round-trip, and it is never deleted. Reasonix has no per-server tool allow/deny lists, but each plugin entry may carry an optional `trusted_read_only_tools` array (raw MCP tool names pre-seeded as trusted for planner/read-only use); rulesync has no deep canonical mapping for it, so it round-trips as a passthrough field on the canonical MCP server object. See the [Reasonix plugins guide](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md#plugins-mcp) and [SPEC.md](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md) (`[[plugins]]` schema). +> **Reasonix note:** MCP servers are written as `[[plugins]]` array-of-tables entries (Reasonix's MCP-compatible external plugins) in `reasonix.toml` (project) / `~/.reasonix/config.toml` (global, via `--global`). Each entry carries a `name` plus the standard transport fields: `type` selects the transport (`stdio` default — `command`/`args`/`env`; `http`, a.k.a. `streamable-http` — `url`/`headers`), and the deprecated `sse` transport is collapsed onto `http`. The file is treated as shared Reasonix config: Rulesync only replaces the `plugins` key and preserves every other table (providers, ui, agent, …) on round-trip, and it is never deleted. Reasonix has no per-server tool allow/deny lists, but each plugin entry may carry an optional `trusted_read_only_tools` array (raw MCP tool names pre-seeded as trusted for planner/read-only use); it also supports `call_timeout_seconds` (a per-server MCP call timeout) and `tool_timeout_seconds` (a per-tool inline table keyed by raw MCP tool name). None of these have a deep canonical mapping, so they round-trip as passthrough fields on the canonical MCP server object. See the [Reasonix plugins guide](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md#plugins-mcp) and [SPEC.md](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md) (`[[plugins]]` schema). ## `.rulesync/.aiignore` or `.rulesyncignore` diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index 5aefa7069..2255fa50c 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -27,7 +27,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Takt | takt | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | | Vibe Code | vibe | ✅ 🌏 | ✅ | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Qwen Code | qwencode | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| Reasonix | reasonix | | | ✅ 🌏 | ✅ 🌏 | | | ✅ 🌏 | ✅ 🌏 | +| Reasonix | reasonix | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Kiro ⚠️ | kiro | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ | ✅ | ✅ | ✅ | ✅ | | Kiro CLI | kiro-cli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ | | Kiro IDE | kiro-ide | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index bc1e5bb03..4db739443 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -68,6 +68,8 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration > **Pi note:** Pi writes the root rule to the auto-loaded `AGENTS.md` (project) / `~/.pi/agent/AGENTS.md` (global, via `--global`) as plain Markdown, and folds non-root rules into that single file (Pi has no modular rules directory). Pi additionally supports two system-prompt instruction files that Rulesync does **not** currently emit: `.pi/SYSTEM.md` (project) / `~/.pi/agent/SYSTEM.md` (global) **replaces** the default system prompt entirely, and `.pi/APPEND_SYSTEM.md` (project) / `~/.pi/agent/APPEND_SYSTEM.md` (global) **appends** to it. Rulesync's rules model only routes a designated `root` rule to a single context file and has no frontmatter convention for marking a rule as "replace the system prompt" versus "append to the system prompt", so these files are left to be authored by hand. See the [Pi usage docs](https://pi.dev/docs/latest/usage). +> **Reasonix note:** Reasonix auto-injects a hierarchical instruction document, reading its vendor-specific `REASONIX.md` (alongside the cross-tool `AGENTS.md`/`CLAUDE.md`) by walking user-home → ancestors → project root/local. Rulesync writes the vendor `REASONIX.md` at the project root (project) / `~/.reasonix/REASONIX.md` (global, via `--global`) and folds non-root rules into that single file, since Reasonix has no modular rules directory. See the [Reasonix GUIDE](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/GUIDE.md). + ## `.rulesync/hooks.json` Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `stop` → `agentStop`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`); Qwen Code emits PascalCase events into the `hooks` key of `.qwen/settings.json` (its supported event set differs from Gemini CLI's). @@ -210,7 +212,7 @@ Events present in the shared `hooks` block but unsupported by a given tool are s > **Note:** Qwen Code hooks are written under the top-level `hooks` key of `.qwen/settings.json` (project) / `~/.qwen/settings.json` (global), using Claude-style PascalCase per-matcher arrays (`{ "EventName": [ { "matcher": "...", "sequential": false, "hooks": [ { "type": "command", "command": "...", "timeout": ... } ] } ] }`). Qwen's supported event set **differs from Gemini CLI's**, so rulesync defines a Qwen-specific mapping. Sixteen lifecycle events are supported — `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `postToolUseFailure` ⇄ `PostToolUseFailure`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `stopFailure` ⇄ `StopFailure`, `subagentStart` ⇄ `SubagentStart`, `subagentStop` ⇄ `SubagentStop`, `preCompact` ⇄ `PreCompact`, `postCompact` ⇄ `PostCompact`, `permissionRequest` ⇄ `PermissionRequest`, `notification` ⇄ `Notification`, `todoCreated` ⇄ `TodoCreated`, and `todoCompleted` ⇄ `TodoCompleted`. Commands are emitted verbatim (no `$GEMINI_PROJECT_DIR` rewriting). Qwen's four hook types are supported: `command`, `prompt`, `http` (which carries a `url` and POSTs JSON to it; the type and URL round-trip), and `function`. Per-hook fields added in [Qwen Code PR #2827](https://github.com/QwenLM/qwen-code/pull/2827) round-trip as well: command hooks carry `async` (run in the background), `env` (extra subprocess environment variables), and `shell` (`bash`/`powershell`); http hooks carry `headers` (with `${VAR}` interpolation), `allowedEnvVars` (the env-var allowlist), and `once` (single execution per event per session); `statusMessage` (progress text) applies to both. Command-only fields are emitted only on command hooks and http-only fields only on http hooks. The group-level `sequential` flag (parallel by default) and the top-level `disableAllHooks` switch are both round-tripped, and other top-level keys in `settings.json` are preserved. See the [Qwen Code hooks docs](https://github.com/QwenLM/qwen-code/blob/main/docs/users/features/hooks.md). -> **Note:** Reasonix hooks are written to a dedicated `.reasonix/settings.json` (project) / `~/.reasonix/settings.json` (global) — a Claude-Code-style but standalone JSON file, separate from the `[permissions]`/`[[plugins]]` TOML config. Unlike Claude Code, each event key maps directly to a **flat array** of hook objects (no `matcher`/`hooks` wrapper): `{ "EventName": [ { "match": "...", "command": "...", "description": "...", "timeout": ... } ] }`. Only four of Reasonix's documented events are mapped — `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, and `stop` ⇄ `Stop` — since the upstream issue scoped the rest (`SessionStart`, `SessionEnd`, `PostLLMCall`, `SubagentStop`, `Notification`, `PreCompact`) as a separate follow-up. `match` (Reasonix's matcher field name) is honored only on `PreToolUse`/`PostToolUse`; a matcher on any other event is dropped with a warning. The canonical `timeout` field is documented in seconds, while Reasonix's `timeout` is milliseconds, so rulesync converts (`× 1000` on generate, `÷ 1000` on import). Only `command`-type hooks are supported. The `settings.json` file is not documented as holding anything besides hooks today, but rulesync merges non-destructively and never deletes it, in case a future Reasonix version adds other keys. See the [Reasonix Hooks guide](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/DESKTOP_HOOKS.zh-CN.md). +> **Note:** Reasonix hooks are written to a dedicated `.reasonix/settings.json` (project) / `~/.reasonix/settings.json` (global) — a Claude-Code-style but standalone JSON file, separate from the `[permissions]`/`[[plugins]]` TOML config. Unlike Claude Code, each event key maps directly to a **flat array** of hook objects (no `matcher`/`hooks` wrapper): `{ "EventName": [ { "match": "...", "command": "...", "description": "...", "timeout": ... } ] }`. Eight of Reasonix's ten documented events are mapped — `preToolUse` ⇄ `PreToolUse`, `postToolUse` ⇄ `PostToolUse`, `beforeSubmitPrompt` ⇄ `UserPromptSubmit`, `stop` ⇄ `Stop`, `sessionStart` ⇄ `SessionStart`, `sessionEnd` ⇄ `SessionEnd`, `subagentStop` ⇄ `SubagentStop`, and `postModelInvocation` ⇄ `PostLLMCall` — while `Notification` and `PreCompact` have no canonical rulesync event and are left out. `match` (Reasonix's matcher field name) is honored only on `PreToolUse`/`PostToolUse`; a matcher on any other event is dropped with a warning. The canonical `timeout` field is documented in seconds, while Reasonix's `timeout` is milliseconds, so rulesync converts (`× 1000` on generate, `÷ 1000` on import). Only `command`-type hooks are supported. The `settings.json` file is not documented as holding anything besides hooks today, but rulesync merges non-destructively and never deletes it, in case a future Reasonix version adds other keys. See the [Reasonix Hooks guide](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/DESKTOP_HOOKS.zh-CN.md). ## `.github/mcp.json` and `.copilot/mcp-config.json` @@ -571,6 +573,8 @@ When `claudecode.scheduled-task: true` is set, that skill is emitted only as a C > **Note:** Codex CLI reads UI metadata, invocation policy, and tool dependencies from an `agents/openai.yaml` sidecar next to `SKILL.md` (Codex's `SKILL.md` frontmatter only carries `name` and `description`). When `codexcli.interface`, `codexcli.policy`, or `codexcli.dependencies` is present, Rulesync emits `.agents/skills//agents/openai.yaml` and reads it back on import. If the sidecar is emitted and `interface.short_description` is absent, the legacy `codexcli.short-description` is routed there. See the [Codex skills docs](https://developers.openai.com/codex/skills.md). +> **Reasonix note:** Reasonix discovers Anthropic-style directory-layout skills (`/SKILL.md`) under `.reasonix/skills/` (project) / `~/.reasonix/skills/` (global, via `--global`). Rulesync emits the portable `name`/`description` frontmatter (Reasonix supports additional optional keys, but only that pair is modeled); the schema is loose, so any extra keys on an imported `SKILL.md` survive the round-trip. See the [Reasonix GUIDE](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/GUIDE.md). + ## `.rulesync/mcp.json` Example: @@ -724,7 +728,7 @@ Subagents live in the `subagents/` subdirectory so the command-recipe and subage Vibe (mistral-vibe) MCP servers live in `[[mcp_servers]]` arrays of the shared `.vibe/config.toml`. In addition to the flat fields, Rulesync passes through the stdio `cwd` (working directory) and a structured per-server `auth` block (Vibe v2.15.0+). The `auth` table is discriminated on `type`: `static` (`headers`, `api_key_env`, `api_key_header`, `api_key_format`) and `oauth` (`scopes`, `client_id` / `client_metadata_url`, `redirect_port`). Because Vibe rejects mixing legacy top-level static-auth keys with an explicit `[auth]` block, Rulesync suppresses the legacy keys (`headers`/`api_key_env`/`api_key_header`/`api_key_format`) whenever a server carries an `auth` block. See [mistral-vibe](https://github.com/mistralai/mistral-vibe) (`vibe/core/config/_settings.py`). -> **Reasonix note:** MCP servers are written as `[[plugins]]` array-of-tables entries (Reasonix's MCP-compatible external plugins) in `reasonix.toml` (project) / `~/.reasonix/config.toml` (global, via `--global`). Each entry carries a `name` plus the standard transport fields: `type` selects the transport (`stdio` default — `command`/`args`/`env`; `http`, a.k.a. `streamable-http` — `url`/`headers`), and the deprecated `sse` transport is collapsed onto `http`. The file is treated as shared Reasonix config: Rulesync only replaces the `plugins` key and preserves every other table (providers, ui, agent, …) on round-trip, and it is never deleted. Reasonix has no per-server tool allow/deny lists, but each plugin entry may carry an optional `trusted_read_only_tools` array (raw MCP tool names pre-seeded as trusted for planner/read-only use); rulesync has no deep canonical mapping for it, so it round-trips as a passthrough field on the canonical MCP server object. See the [Reasonix plugins guide](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md#plugins-mcp) and [SPEC.md](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md) (`[[plugins]]` schema). +> **Reasonix note:** MCP servers are written as `[[plugins]]` array-of-tables entries (Reasonix's MCP-compatible external plugins) in `reasonix.toml` (project) / `~/.reasonix/config.toml` (global, via `--global`). Each entry carries a `name` plus the standard transport fields: `type` selects the transport (`stdio` default — `command`/`args`/`env`; `http`, a.k.a. `streamable-http` — `url`/`headers`), and the deprecated `sse` transport is collapsed onto `http`. The file is treated as shared Reasonix config: Rulesync only replaces the `plugins` key and preserves every other table (providers, ui, agent, …) on round-trip, and it is never deleted. Reasonix has no per-server tool allow/deny lists, but each plugin entry may carry an optional `trusted_read_only_tools` array (raw MCP tool names pre-seeded as trusted for planner/read-only use); it also supports `call_timeout_seconds` (a per-server MCP call timeout) and `tool_timeout_seconds` (a per-tool inline table keyed by raw MCP tool name). None of these have a deep canonical mapping, so they round-trip as passthrough fields on the canonical MCP server object. See the [Reasonix plugins guide](https://github.com/esengine/deepseek-reasonix/blob/main-v2/docs/GUIDE.md#plugins-mcp) and [SPEC.md](https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md) (`[[plugins]]` schema). ## `.rulesync/.aiignore` or `.rulesyncignore` diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index 5aefa7069..2255fa50c 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -27,7 +27,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Takt | takt | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | | Vibe Code | vibe | ✅ 🌏 | ✅ | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Qwen Code | qwencode | ✅ 🌏 | ✅ | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| Reasonix | reasonix | | | ✅ 🌏 | ✅ 🌏 | | | ✅ 🌏 | ✅ 🌏 | +| Reasonix | reasonix | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Kiro ⚠️ | kiro | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ | ✅ | ✅ | ✅ | ✅ | | Kiro CLI | kiro-cli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ | | Kiro IDE | kiro-ide | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | diff --git a/src/constants/reasonix-paths.ts b/src/constants/reasonix-paths.ts index 17917d521..9a81c1b9c 100644 --- a/src/constants/reasonix-paths.ts +++ b/src/constants/reasonix-paths.ts @@ -25,3 +25,15 @@ export const REASONIX_SETTINGS_FILE_NAME = "settings.json"; // Custom slash commands: Markdown files under `.reasonix/commands/` (project) / // `~/.reasonix/commands/` (global). export const REASONIX_COMMANDS_DIR_PATH = join(REASONIX_DIR, "commands"); + +// Reasonix reads `REASONIX.md` as its primary vendor instruction/rules file, +// discovered hierarchically (user-home → ancestors → project root/local). It +// also recognizes the cross-tool `AGENTS.md`/`CLAUDE.md`, but rulesync emits the +// vendor-specific `REASONIX.md` at the project root (project scope) and +// `~/.reasonix/REASONIX.md` (global scope, via the processor's home-relative +// outputRoot under REASONIX_GLOBAL_DIR). +export const REASONIX_RULE_FILE_NAME = "REASONIX.md"; + +// Skills: Anthropic-style directory-layout skills under `.reasonix/skills/` +// (project) / `~/.reasonix/skills/` (global), each `/SKILL.md`. +export const REASONIX_SKILLS_DIR_PATH = join(REASONIX_DIR, "skills"); diff --git a/src/e2e/e2e-hooks.spec.ts b/src/e2e/e2e-hooks.spec.ts index 442f00fab..fe3180f4a 100644 --- a/src/e2e/e2e-hooks.spec.ts +++ b/src/e2e/e2e-hooks.spec.ts @@ -296,8 +296,9 @@ describe("E2E: hooks", () => { it("should generate reasonix hooks (.reasonix/settings.json, flat per-event arrays)", async () => { const testDir = getTestDir(); - // Reasonix only maps four events (PreToolUse/PostToolUse/UserPromptSubmit/Stop); - // sessionStart has no mapped Reasonix equivalent in rulesync's scoped surface. + // Reasonix maps eight events; sessionStart ⇄ SessionStart and + // postModelInvocation ⇄ PostLLMCall are among them, while preCompact has no + // mapped Reasonix equivalent in rulesync's scoped surface and is dropped. const hooksContent = JSON.stringify( { version: 1, @@ -305,6 +306,8 @@ describe("E2E: hooks", () => { preToolUse: [{ command: ".rulesync/hooks/pre-tool.sh", matcher: "bash", timeout: 5 }], stop: [{ command: ".rulesync/hooks/audit.sh" }], sessionStart: [{ command: ".rulesync/hooks/session-start.sh" }], + postModelInvocation: [{ command: ".rulesync/hooks/post-llm.sh" }], + preCompact: [{ command: ".rulesync/hooks/pre-compact.sh" }], }, }, null, @@ -321,7 +324,9 @@ describe("E2E: hooks", () => { { match: "bash", command: ".rulesync/hooks/pre-tool.sh", timeout: 5000 }, ]); expect(parsed.hooks.Stop).toEqual([{ command: ".rulesync/hooks/audit.sh" }]); - expect(parsed.hooks.SessionStart).toBeUndefined(); + expect(parsed.hooks.SessionStart).toEqual([{ command: ".rulesync/hooks/session-start.sh" }]); + expect(parsed.hooks.PostLLMCall).toEqual([{ command: ".rulesync/hooks/post-llm.sh" }]); + expect(parsed.hooks.PreCompact).toBeUndefined(); }); it("should import reasonix hooks from .reasonix/settings.json", async () => { diff --git a/src/e2e/e2e-rules.spec.ts b/src/e2e/e2e-rules.spec.ts index b2d473f38..76e972ee9 100644 --- a/src/e2e/e2e-rules.spec.ts +++ b/src/e2e/e2e-rules.spec.ts @@ -45,6 +45,7 @@ const rulesRootTargets = [ { target: "pi", outputPath: "AGENTS.md" }, { target: "zed", outputPath: ".rules" }, { target: "vibe", outputPath: "AGENTS.md" }, + { target: "reasonix", outputPath: "REASONIX.md" }, ] as const; // Tools that emit every rule as a directory entry. @@ -688,6 +689,7 @@ const rulesGlobalTargets = [ { target: "kiro", outputPath: join(".kiro", "steering", "product.md") }, { target: "kiro-cli", outputPath: join(".kiro", "steering", "product.md") }, { target: "kiro-ide", outputPath: join(".kiro", "steering", "product.md") }, + { target: "reasonix", outputPath: join(".reasonix", "REASONIX.md") }, ] as const; describe("E2E: rules (global mode)", () => { diff --git a/src/e2e/e2e-skills.spec.ts b/src/e2e/e2e-skills.spec.ts index ee4a32190..ca6a7cd2d 100644 --- a/src/e2e/e2e-skills.spec.ts +++ b/src/e2e/e2e-skills.spec.ts @@ -143,6 +143,10 @@ const skillsGenerateTargets = [ target: "kiro-ide", outputPath: join(".kiro", "skills", "test-skill", "SKILL.md"), }, + { + target: "reasonix", + outputPath: join(".reasonix", "skills", "test-skill", "SKILL.md"), + }, ] as const; describe("E2E: skills", () => { @@ -454,6 +458,11 @@ const skillsGlobalTargets = [ target: "kiro-ide", outputPath: join(".kiro", "skills", "test-skill", "SKILL.md"), }, + { + // Reasonix reads global skills from ~/.reasonix/skills/. + target: "reasonix", + outputPath: join(".reasonix", "skills", "test-skill", "SKILL.md"), + }, ] as const; describe("E2E: skills (global mode)", () => { diff --git a/src/features/hooks/reasonix-hooks.test.ts b/src/features/hooks/reasonix-hooks.test.ts index 67d57c6c5..e63daabe8 100644 --- a/src/features/hooks/reasonix-hooks.test.ts +++ b/src/features/hooks/reasonix-hooks.test.ts @@ -39,7 +39,7 @@ describe("ReasonixHooks", () => { }); describe("fromRulesyncHooks", () => { - it("should filter shared hooks to the four documented Reasonix events", async () => { + it("should map the eight supported Reasonix events and drop unsupported ones", async () => { await ensureDir(join(testDir, ".reasonix")); await writeFileContent(join(testDir, ".reasonix", "settings.json"), JSON.stringify({})); @@ -51,6 +51,11 @@ describe("ReasonixHooks", () => { beforeSubmitPrompt: [{ command: ".rulesync/hooks/prompt.sh" }], stop: [{ command: ".rulesync/hooks/audit.sh" }], sessionStart: [{ command: ".rulesync/hooks/session-start.sh" }], + sessionEnd: [{ command: ".rulesync/hooks/session-end.sh" }], + subagentStop: [{ command: ".rulesync/hooks/subagent-stop.sh" }], + postModelInvocation: [{ command: ".rulesync/hooks/post-llm.sh" }], + // preCompact has no canonical mapping in the scoped event set. + preCompact: [{ command: ".rulesync/hooks/pre-compact.sh" }], }, }; const rulesyncHooks = new RulesyncHooks({ @@ -72,8 +77,13 @@ describe("ReasonixHooks", () => { expect(parsed.hooks.PostToolUse).toBeDefined(); expect(parsed.hooks.UserPromptSubmit).toBeDefined(); expect(parsed.hooks.Stop).toBeDefined(); - // sessionStart has no Reasonix mapping in the scoped event set. - expect(parsed.hooks.SessionStart).toBeUndefined(); + // postModelInvocation ← PostLLMCall, plus the session/subagent lifecycle. + expect(parsed.hooks.SessionStart).toBeDefined(); + expect(parsed.hooks.SessionEnd).toBeDefined(); + expect(parsed.hooks.SubagentStop).toBeDefined(); + expect(parsed.hooks.PostLLMCall).toBeDefined(); + // preCompact is not in the mapped canonical set, so it is dropped. + expect(parsed.hooks.PreCompact).toBeUndefined(); }); it("should emit a flat array of hook objects per event (no matcher-group wrapper)", async () => { diff --git a/src/features/hooks/reasonix-hooks.ts b/src/features/hooks/reasonix-hooks.ts index 2786f8ef5..1a843246b 100644 --- a/src/features/hooks/reasonix-hooks.ts +++ b/src/features/hooks/reasonix-hooks.ts @@ -156,8 +156,9 @@ function reasonixHooksToCanonical(hooks: unknown): HooksConfig["hooks"] { * Reasonix hooks live in a Claude-Code-style but standalone JSON file — * `.reasonix/settings.json` (project) or `~/.reasonix/settings.json` * (global) — separate from the `[permissions]`/`[[plugins]]` TOML config. - * Only the four events documented in the upstream issue are mapped: - * PreToolUse/PostToolUse/UserPromptSubmit/Stop (see REASONIX_HOOK_EVENTS). + * The eight upstream events with a clean canonical equivalent are mapped: + * PreToolUse/PostToolUse/UserPromptSubmit/Stop plus SessionStart/SessionEnd/ + * SubagentStop/PostLLMCall (see REASONIX_HOOK_EVENTS). * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/DESKTOP_HOOKS.zh-CN.md */ export class ReasonixHooks extends ToolHooks { diff --git a/src/features/mcp/reasonix-mcp.ts b/src/features/mcp/reasonix-mcp.ts index eaae08192..073c276bf 100644 --- a/src/features/mcp/reasonix-mcp.ts +++ b/src/features/mcp/reasonix-mcp.ts @@ -39,6 +39,10 @@ type ReasonixPlugin = Record & { // it round-trips as a passthrough field on the canonical McpServer (a loose // zod object, so unknown keys survive), mirroring how other MCP adapters // preserve server-specific extra fields they don't deeply model. +// `call_timeout_seconds` (per-server MCP call timeout) and `tool_timeout_seconds` +// (a per-tool inline table keyed by raw MCP tool name) are likewise Reasonix-only +// `[[plugins]]` fields with no canonical equivalent, so they round-trip as +// passthrough fields too. // @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md const REASONIX_PLUGIN_FIELDS = [ "type", @@ -48,6 +52,8 @@ const REASONIX_PLUGIN_FIELDS = [ "url", "headers", "trusted_read_only_tools", + "call_timeout_seconds", + "tool_timeout_seconds", ] as const; export class ReasonixMcp extends ToolMcp { diff --git a/src/features/rules/reasonix-rule.test.ts b/src/features/rules/reasonix-rule.test.ts new file mode 100644 index 000000000..8214a3b62 --- /dev/null +++ b/src/features/rules/reasonix-rule.test.ts @@ -0,0 +1,107 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { RULESYNC_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { writeFileContent } from "../../utils/file.js"; +import { ReasonixRule } from "./reasonix-rule.js"; +import { RulesyncRule } from "./rulesync-rule.js"; + +describe("ReasonixRule", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("should target REASONIX.md at the project root in project mode", () => { + const { root } = ReasonixRule.getSettablePaths(); + expect(root).toEqual({ relativeDirPath: ".", relativeFilePath: "REASONIX.md" }); + }); + + it("should target .reasonix/REASONIX.md in global mode", () => { + const { root } = ReasonixRule.getSettablePaths({ global: true }); + expect(root).toEqual({ relativeDirPath: ".reasonix", relativeFilePath: "REASONIX.md" }); + }); + }); + + describe("fromFile", () => { + it("should load the root REASONIX.md regardless of the requested relativeFilePath", async () => { + const content = "# Reasonix Instructions\n\nSingle source of truth."; + await writeFileContent(join(testDir, "REASONIX.md"), content); + + const rule = await ReasonixRule.fromFile({ + outputRoot: testDir, + relativeFilePath: "error-handling.md", + }); + + expect(rule.getFileContent()).toBe(content); + expect(rule.getRelativeDirPath()).toBe("."); + expect(rule.getRelativeFilePath()).toBe("REASONIX.md"); + expect(rule.isRoot()).toBe(true); + }); + + it("should load the global REASONIX.md from .reasonix/", async () => { + const content = "# Global Reasonix Instructions"; + await writeFileContent(join(testDir, ".reasonix", "REASONIX.md"), content); + + const rule = await ReasonixRule.fromFile({ + outputRoot: testDir, + relativeFilePath: "REASONIX.md", + global: true, + }); + + expect(rule.getFileContent()).toBe(content); + expect(rule.getRelativeDirPath()).toBe(".reasonix"); + }); + }); + + describe("fromRulesyncRule / toRulesyncRule", () => { + it("should emit the rule body to REASONIX.md and round-trip back", () => { + const body = "# Team Rules\n\nAlways write tests."; + const rulesyncRule = new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "overview.md", + frontmatter: { root: true, targets: ["*"], description: "", globs: ["**/*"] }, + body, + validate: false, + }); + + const rule = ReasonixRule.fromRulesyncRule({ outputRoot: testDir, rulesyncRule }); + expect(rule.getRelativeFilePath()).toBe("REASONIX.md"); + expect(rule.getRelativeDirPath()).toBe("."); + expect(rule.getFileContent()).toBe(body); + + const back = rule.toRulesyncRule(); + expect(back.getBody()).toBe(body); + }); + }); + + describe("isTargetedByRulesyncRule", () => { + it("should target reasonix for wildcard and explicit targets, not others", () => { + const make = (targets: ("*" | "reasonix" | "cursor")[]) => + new RulesyncRule({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "overview.md", + frontmatter: { root: false, targets, description: "", globs: [] }, + body: "x", + validate: false, + }); + + expect(ReasonixRule.isTargetedByRulesyncRule(make(["*"]))).toBe(true); + expect(ReasonixRule.isTargetedByRulesyncRule(make(["reasonix"]))).toBe(true); + expect(ReasonixRule.isTargetedByRulesyncRule(make(["cursor"]))).toBe(false); + }); + }); +}); diff --git a/src/features/rules/reasonix-rule.ts b/src/features/rules/reasonix-rule.ts new file mode 100644 index 000000000..0ac90236b --- /dev/null +++ b/src/features/rules/reasonix-rule.ts @@ -0,0 +1,135 @@ +import { join } from "node:path"; + +import { REASONIX_GLOBAL_DIR, REASONIX_RULE_FILE_NAME } from "../../constants/reasonix-paths.js"; +import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { readFileContent } from "../../utils/file.js"; +import { RulesyncRule } from "./rulesync-rule.js"; +import { + ToolRule, + ToolRuleForDeletionParams, + ToolRuleFromFileParams, + ToolRuleFromRulesyncRuleParams, + ToolRuleSettablePaths, +} from "./tool-rule.js"; + +export type ReasonixRuleParams = AiFileParams & { + root?: boolean; +}; + +/** + * Rule generator for DeepSeek-Reasonix. + * + * Reasonix auto-injects a hierarchical instruction document, reading its + * vendor-specific `REASONIX.md` (alongside the cross-tool `AGENTS.md`/`CLAUDE.md`) + * discovered by walking user-home → ancestors → project root/local. rulesync + * emits the vendor `REASONIX.md` at the project root (project scope) and + * `~/.reasonix/REASONIX.md` (global scope). Like codexcli/grokcli/warp, there is + * no non-root instruction directory to map rulesync's topic rules onto, so their + * bodies are folded into the single root `REASONIX.md` by the RulesProcessor + * (`nonRoot` is `undefined`). + * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/GUIDE.md + */ +export type ReasonixRuleSettablePaths = Pick & { + root: { + relativeDirPath: string; + relativeFilePath: string; + }; + nonRoot?: undefined; +}; + +export class ReasonixRule extends ToolRule { + constructor({ fileContent, root, ...rest }: ReasonixRuleParams) { + super({ + ...rest, + fileContent, + root: root ?? false, + }); + } + + static getSettablePaths({ + global = false, + }: { + global?: boolean; + excludeToolDir?: boolean; + } = {}): ReasonixRuleSettablePaths { + return { + root: { + relativeDirPath: global ? REASONIX_GLOBAL_DIR : ".", + relativeFilePath: REASONIX_RULE_FILE_NAME, + }, + }; + } + + static async fromFile({ + outputRoot = process.cwd(), + relativeFilePath: _relativeFilePath, + validate = true, + global = false, + }: ToolRuleFromFileParams): Promise { + const { root } = this.getSettablePaths({ global }); + const relativePath = join(root.relativeDirPath, root.relativeFilePath); + const fileContent = await readFileContent(join(outputRoot, relativePath)); + + return new ReasonixRule({ + outputRoot, + relativeDirPath: root.relativeDirPath, + relativeFilePath: root.relativeFilePath, + fileContent, + validate, + root: true, + }); + } + + static fromRulesyncRule({ + outputRoot = process.cwd(), + rulesyncRule, + validate = true, + global = false, + }: ToolRuleFromRulesyncRuleParams): ReasonixRule { + const { root } = this.getSettablePaths({ global }); + const isRoot = rulesyncRule.getFrontmatter().root ?? false; + + return new ReasonixRule({ + outputRoot, + relativeDirPath: root.relativeDirPath, + relativeFilePath: root.relativeFilePath, + fileContent: rulesyncRule.getBody(), + validate, + root: isRoot, + }); + } + + toRulesyncRule(): RulesyncRule { + return this.toRulesyncRuleDefault(); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolRuleForDeletionParams): ReasonixRule { + const isRoot = + relativeFilePath === REASONIX_RULE_FILE_NAME && + (relativeDirPath === "." || relativeDirPath === REASONIX_GLOBAL_DIR); + + return new ReasonixRule({ + outputRoot, + relativeDirPath, + relativeFilePath, + fileContent: "", + validate: false, + root: isRoot, + }); + } + + static isTargetedByRulesyncRule(rulesyncRule: RulesyncRule): boolean { + return this.isTargetedByRulesyncRuleDefault({ + rulesyncRule, + toolTarget: "reasonix", + }); + } +} diff --git a/src/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index 49d6829e6..924ee74a7 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -959,6 +959,7 @@ Content that would fail parsing`; "opencode", "pi", "qwencode", + "reasonix", "roo", "rovodev", "takt", @@ -1012,7 +1013,8 @@ Content that would fail parsing`; expect(globalTargets).toContain("kiro"); expect(globalTargets).toContain("kiro-cli"); expect(globalTargets).toContain("kiro-ide"); - expect(globalTargets.length).toBe(28); + expect(globalTargets).toContain("reasonix"); + expect(globalTargets.length).toBe(29); // These targets should NOT be in global mode expect(globalTargets).not.toContain("cursor"); diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index b5a52e339..247328f1e 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -54,6 +54,7 @@ import { KiroRule } from "./kiro-rule.js"; import { OpenCodeRule } from "./opencode-rule.js"; import { PiRule } from "./pi-rule.js"; import { QwencodeRule } from "./qwencode-rule.js"; +import { ReasonixRule } from "./reasonix-rule.js"; import { ReplitRule } from "./replit-rule.js"; import { RooRule } from "./roo-rule.js"; import { RovodevRule } from "./rovodev-rule.js"; @@ -612,6 +613,21 @@ export const toolRuleFactories = new Map { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("should return .reasonix/skills for both project and global mode", () => { + expect(ReasonixSkill.getSettablePaths().relativeDirPath).toBe(join(".reasonix", "skills")); + expect(ReasonixSkill.getSettablePaths({ global: true }).relativeDirPath).toBe( + join(".reasonix", "skills"), + ); + }); + }); + + describe("fromRulesyncSkill / toRulesyncSkill", () => { + it("should emit a name/description SKILL.md and round-trip back", () => { + const frontmatter: RulesyncSkillFrontmatterInput = { + name: "test-skill", + description: "A test skill", + targets: ["*"], + }; + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + dirName: "test-skill", + frontmatter, + body: "Skill body", + validate: false, + }); + + const skill = ReasonixSkill.fromRulesyncSkill({ outputRoot: testDir, rulesyncSkill }); + expect(skill.getRelativeDirPath()).toBe(join(".reasonix", "skills")); + expect(skill.getFrontmatter()).toEqual({ name: "test-skill", description: "A test skill" }); + expect(skill.getBody()).toBe("Skill body"); + + const back = skill.toRulesyncSkill(); + expect(back.getFrontmatter().name).toBe("test-skill"); + expect(back.getFrontmatter().description).toBe("A test skill"); + expect(back.getBody()).toBe("Skill body"); + }); + }); + + describe("fromDir", () => { + it("should load a directory-layout SKILL.md", async () => { + const skillDir = join(testDir, ".reasonix", "skills", "my-skill"); + await ensureDir(skillDir); + await writeFileContent( + join(skillDir, SKILL_FILE_NAME), + `---\nname: my-skill\ndescription: Loaded from disk\n---\n\nBody here`, + ); + + const skill = await ReasonixSkill.fromDir({ + outputRoot: testDir, + relativeDirPath: join(".reasonix", "skills"), + dirName: "my-skill", + }); + + expect(skill.getFrontmatter()).toEqual({ name: "my-skill", description: "Loaded from disk" }); + expect(skill.getBody().trim()).toBe("Body here"); + }); + }); + + describe("isTargetedByRulesyncSkill", () => { + it("should target reasonix for wildcard and explicit targets, not others", () => { + const make = (targets: ("*" | "reasonix" | "claudecode")[]) => + new RulesyncSkill({ + outputRoot: testDir, + dirName: "s", + frontmatter: { name: "s", description: "d", targets }, + body: "b", + validate: false, + }); + + expect(ReasonixSkill.isTargetedByRulesyncSkill(make(["*"]))).toBe(true); + expect(ReasonixSkill.isTargetedByRulesyncSkill(make(["reasonix"]))).toBe(true); + expect(ReasonixSkill.isTargetedByRulesyncSkill(make(["claudecode"]))).toBe(false); + }); + }); +}); diff --git a/src/features/skills/reasonix-skill.ts b/src/features/skills/reasonix-skill.ts new file mode 100644 index 000000000..98260c174 --- /dev/null +++ b/src/features/skills/reasonix-skill.ts @@ -0,0 +1,217 @@ +import { join } from "node:path"; + +import { z } from "zod/mini"; + +import { SKILL_FILE_NAME } from "../../constants/general.js"; +import { REASONIX_SKILLS_DIR_PATH } from "../../constants/reasonix-paths.js"; +import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { ValidationResult } from "../../types/ai-dir.js"; +import { formatError } from "../../utils/error.js"; +import { RulesyncSkill, RulesyncSkillFrontmatterInput, SkillFile } from "./rulesync-skill.js"; +import { + ToolSkill, + ToolSkillForDeletionParams, + ToolSkillFromDirParams, + ToolSkillFromRulesyncSkillParams, + ToolSkillSettablePaths, +} from "./tool-skill.js"; + +// Reasonix skills use the Anthropic Agent Skills format: a `/SKILL.md` +// directory whose YAML frontmatter carries `name`/`description` (the same shape +// the canonical rulesync skill adapter emits). Reasonix supports additional +// optional keys (allowed-tools/model/effort/…), but rulesync models only the +// portable `name`/`description` pair; the schema is loose so any extra keys on +// an imported file survive the round-trip. +export const ReasonixSkillFrontmatterSchema = z.looseObject({ + name: z.string(), + description: z.string(), +}); + +export type ReasonixSkillFrontmatter = z.infer; + +export type ReasonixSkillParams = { + outputRoot?: string; + relativeDirPath?: string; + dirName: string; + frontmatter: ReasonixSkillFrontmatter; + body: string; + otherFiles?: SkillFile[]; + validate?: boolean; + global?: boolean; +}; + +/** + * Represents a DeepSeek-Reasonix skill directory. + * + * Reasonix discovers directory-layout skills (`/SKILL.md`) under + * `.reasonix/skills/` (project) and `~/.reasonix/skills/` (global); the global + * scope is served by the processor supplying the home directory as outputRoot. + * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/GUIDE.md + */ +export class ReasonixSkill extends ToolSkill { + constructor({ + outputRoot = process.cwd(), + relativeDirPath = REASONIX_SKILLS_DIR_PATH, + dirName, + frontmatter, + body, + otherFiles = [], + validate = true, + global = false, + }: ReasonixSkillParams) { + super({ + outputRoot, + relativeDirPath, + dirName, + mainFile: { + name: SKILL_FILE_NAME, + body, + frontmatter: { ...frontmatter }, + }, + otherFiles, + global, + }); + + if (validate) { + const result = this.validate(); + if (!result.success) { + throw result.error; + } + } + } + + static getSettablePaths({ + global: _global = false, + }: { + global?: boolean; + } = {}): ToolSkillSettablePaths { + return { + relativeDirPath: REASONIX_SKILLS_DIR_PATH, + }; + } + + getFrontmatter(): ReasonixSkillFrontmatter { + return ReasonixSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter()); + } + + getBody(): string { + return this.mainFile?.body ?? ""; + } + + validate(): ValidationResult { + if (this.mainFile === undefined) { + return { + success: false, + error: new Error(`${this.getDirPath()}: ${SKILL_FILE_NAME} file does not exist`), + }; + } + const result = ReasonixSkillFrontmatterSchema.safeParse(this.mainFile.frontmatter); + if (!result.success) { + return { + success: false, + error: new Error( + `Invalid frontmatter in ${this.getDirPath()}: ${formatError(result.error)}`, + ), + }; + } + + return { success: true, error: null }; + } + + toRulesyncSkill(): RulesyncSkill { + const frontmatter = this.getFrontmatter(); + const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = { + name: frontmatter.name, + description: frontmatter.description, + targets: ["*"], + }; + + return new RulesyncSkill({ + outputRoot: this.outputRoot, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: this.getDirName(), + frontmatter: rulesyncFrontmatter, + body: this.getBody(), + otherFiles: this.getOtherFiles(), + validate: true, + global: this.global, + }); + } + + static fromRulesyncSkill({ + outputRoot = process.cwd(), + rulesyncSkill, + validate = true, + global = false, + }: ToolSkillFromRulesyncSkillParams): ReasonixSkill { + const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); + + const reasonixFrontmatter: ReasonixSkillFrontmatter = { + name: rulesyncFrontmatter.name, + description: rulesyncFrontmatter.description, + }; + + const settablePaths = ReasonixSkill.getSettablePaths({ global }); + + return new ReasonixSkill({ + outputRoot, + relativeDirPath: settablePaths.relativeDirPath, + dirName: rulesyncSkill.getDirName(), + frontmatter: reasonixFrontmatter, + body: rulesyncSkill.getBody(), + otherFiles: rulesyncSkill.getOtherFiles(), + validate, + global, + }); + } + + static isTargetedByRulesyncSkill(rulesyncSkill: RulesyncSkill): boolean { + const frontmatter = rulesyncSkill.getFrontmatter(); + const targets = frontmatter.targets; + return targets.includes("*") || targets.includes("reasonix"); + } + + static async fromDir(params: ToolSkillFromDirParams): Promise { + const loaded = await this.loadSkillDirContent({ + ...params, + getSettablePaths: ReasonixSkill.getSettablePaths, + }); + + const result = ReasonixSkillFrontmatterSchema.safeParse(loaded.frontmatter); + if (!result.success) { + const skillDirPath = join(loaded.outputRoot, loaded.relativeDirPath, loaded.dirName); + throw new Error( + `Invalid frontmatter in ${join(skillDirPath, SKILL_FILE_NAME)}: ${formatError(result.error)}`, + ); + } + + return new ReasonixSkill({ + outputRoot: loaded.outputRoot, + relativeDirPath: loaded.relativeDirPath, + dirName: loaded.dirName, + frontmatter: result.data, + body: loaded.body, + otherFiles: loaded.otherFiles, + validate: true, + global: loaded.global, + }); + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + dirName, + global = false, + }: ToolSkillForDeletionParams): ReasonixSkill { + return new ReasonixSkill({ + outputRoot, + relativeDirPath, + dirName, + frontmatter: { name: "", description: "" }, + body: "", + otherFiles: [], + validate: false, + global, + }); + } +} diff --git a/src/features/skills/skills-processor.test.ts b/src/features/skills/skills-processor.test.ts index b3002aa3a..489569aef 100644 --- a/src/features/skills/skills-processor.test.ts +++ b/src/features/skills/skills-processor.test.ts @@ -878,6 +878,7 @@ Content that would fail parsing`; "opencode", "pi", "qwencode", + "reasonix", "replit", "roo", "rovodev", @@ -920,6 +921,7 @@ Content that would fail parsing`; "opencode", "pi", "qwencode", + "reasonix", "replit", "roo", "rovodev", @@ -961,6 +963,7 @@ Content that would fail parsing`; "opencode", "pi", "qwencode", + "reasonix", "replit", "roo", "rovodev", @@ -1012,6 +1015,7 @@ Content that would fail parsing`; "opencode", "pi", "qwencode", + "reasonix", "replit", "roo", "rovodev", @@ -1052,6 +1056,7 @@ Content that would fail parsing`; "opencode", "pi", "qwencode", + "reasonix", "replit", "roo", "rovodev", diff --git a/src/features/skills/skills-processor.ts b/src/features/skills/skills-processor.ts index 83e8f0ddb..d07f8a1a3 100644 --- a/src/features/skills/skills-processor.ts +++ b/src/features/skills/skills-processor.ts @@ -38,6 +38,7 @@ import { KiroSkill } from "./kiro-skill.js"; import { OpenCodeSkill } from "./opencode-skill.js"; import { PiSkill } from "./pi-skill.js"; import { QwencodeSkill } from "./qwencode-skill.js"; +import { ReasonixSkill } from "./reasonix-skill.js"; import { ReplitSkill } from "./replit-skill.js"; import { RooSkill } from "./roo-skill.js"; import { RovodevSkill } from "./rovodev-skill.js"; @@ -309,6 +310,16 @@ export const toolSkillFactories = new Map/SKILL.md) + // under .reasonix/skills/ (project) and ~/.reasonix/skills/ (global). + // https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/GUIDE.md + class: ReasonixSkill, + meta: { supportsProject: true, supportsSimulated: false, supportsGlobal: true }, + }, + ], [ "replit", { diff --git a/src/types/hooks.ts b/src/types/hooks.ts index 1f4e79908..2845b608e 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -504,11 +504,14 @@ export const QWENCODE_HOOK_EVENTS: readonly HookEvent[] = [ * Reasonix's `.reasonix/settings.json` (project) / `~/.reasonix/settings.json` * (global) documents a ten-event surface (`PreToolUse`, `PostToolUse`, * `UserPromptSubmit`, `Stop`, `PostLLMCall`, `SessionStart`, `SessionEnd`, - * `SubagentStop`, `Notification`, `PreCompact`), but only the four events the - * upstream issue scoped in are mapped here: `PreToolUse`, `PostToolUse`, - * `UserPromptSubmit` ← `beforeSubmitPrompt`, and `Stop`. `match` (Reasonix's - * matcher field name) is honored only on `PreToolUse`/`PostToolUse`, matching - * the canonical `matcher` field's tool-event scoping used by other adapters. + * `SubagentStop`, `Notification`, `PreCompact`). The eight events with a clean + * canonical equivalent are mapped: `PreToolUse`, `PostToolUse`, + * `UserPromptSubmit` ← `beforeSubmitPrompt`, `Stop`, `SessionStart`, + * `SessionEnd`, `SubagentStop`, and `PostLLMCall` ← `postModelInvocation`. + * (`Notification` and `PreCompact` have no canonical event and are left out.) + * `match` (Reasonix's matcher field name) is honored only on + * `PreToolUse`/`PostToolUse`, matching the canonical `matcher` field's + * tool-event scoping used by other adapters. * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/DESKTOP_HOOKS.zh-CN.md */ export const REASONIX_HOOK_EVENTS: readonly HookEvent[] = [ @@ -516,6 +519,10 @@ export const REASONIX_HOOK_EVENTS: readonly HookEvent[] = [ "postToolUse", "beforeSubmitPrompt", "stop", + "sessionStart", + "sessionEnd", + "subagentStop", + "postModelInvocation", ]; /** @@ -1035,7 +1042,7 @@ export const QWENCODE_TO_CANONICAL_EVENT_NAMES: Record = Object. /** * Map canonical camelCase event names to Reasonix PascalCase. * Reasonix explicitly mirrors Claude Code's hooks model, so it reuses the same - * PascalCase names for the four events rulesync maps. + * PascalCase names for the events rulesync maps. * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/DESKTOP_HOOKS.zh-CN.md */ export const CANONICAL_TO_REASONIX_EVENT_NAMES: Record = { @@ -1043,6 +1050,10 @@ export const CANONICAL_TO_REASONIX_EVENT_NAMES: Record = { postToolUse: "PostToolUse", beforeSubmitPrompt: "UserPromptSubmit", stop: "Stop", + sessionStart: "SessionStart", + sessionEnd: "SessionEnd", + subagentStop: "SubagentStop", + postModelInvocation: "PostLLMCall", }; /** diff --git a/src/types/tool-target-tuples.ts b/src/types/tool-target-tuples.ts index 16e47fca3..5bc389511 100644 --- a/src/types/tool-target-tuples.ts +++ b/src/types/tool-target-tuples.ts @@ -30,6 +30,7 @@ export const rulesProcessorToolTargetTuple = [ "opencode", "pi", "qwencode", + "reasonix", "replit", "roo", "rovodev", @@ -182,6 +183,7 @@ export const skillsProcessorToolTargetTuple = [ "opencode", "pi", "qwencode", + "reasonix", "replit", "roo", "rovodev", diff --git a/src/types/tool-targets.test.ts b/src/types/tool-targets.test.ts index d6f6238e8..e9f17d704 100644 --- a/src/types/tool-targets.test.ts +++ b/src/types/tool-targets.test.ts @@ -43,6 +43,7 @@ describe("tool targets", () => { "opencode", "pi", "qwencode", + "reasonix", "replit", "roo", "rovodev", @@ -51,7 +52,6 @@ describe("tool targets", () => { "warp", "devin", "zed", - "reasonix", "agentsskills", ]; From 7c1cf50298334e254d6b3c406ead0521f7253447 Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Wed, 8 Jul 2026 22:13:48 -0700 Subject: [PATCH 2/2] test(reasonix): cover MCP plugin timeout fields round-trip Address a mid review finding on #2193: add export/import/round-trip tests for the new call_timeout_seconds (per-server scalar) and tool_timeout_seconds (per-tool inline table) plugin passthrough fields, including the nested-table serialize path. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/features/mcp/reasonix-mcp.test.ts | 76 +++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/features/mcp/reasonix-mcp.test.ts b/src/features/mcp/reasonix-mcp.test.ts index 4e2246313..2363fbfd9 100644 --- a/src/features/mcp/reasonix-mcp.test.ts +++ b/src/features/mcp/reasonix-mcp.test.ts @@ -255,4 +255,80 @@ describe("ReasonixMcp", () => { expect(parsed.plugins[0].trusted_read_only_tools).toBeUndefined(); }); }); + + describe("plugin timeout fields round-trip", () => { + it("should export call_timeout_seconds (per-server) and tool_timeout_seconds (per-tool table)", async () => { + const rulesyncMcp = new RulesyncMcp({ + outputRoot: testDir, + relativeDirPath: ".rulesync", + relativeFilePath: "mcp.json", + fileContent: JSON.stringify({ + mcpServers: { + media: { + command: "reasonix-plugin-media", + call_timeout_seconds: 600, + tool_timeout_seconds: { generate_video: 1800 }, + }, + }, + }), + }); + + const reasonixMcp = await ReasonixMcp.fromRulesyncMcp({ outputRoot: testDir, rulesyncMcp }); + const parsed = smolToml.parse(reasonixMcp.getFileContent()) as any; + + expect(parsed.plugins[0]).toMatchObject({ + name: "media", + call_timeout_seconds: 600, + tool_timeout_seconds: { generate_video: 1800 }, + }); + }); + + it("should import both timeout fields from an existing [[plugins]] entry", () => { + const fileContent = [ + "[[plugins]]", + 'name = "media"', + 'command = "reasonix-plugin-media"', + "call_timeout_seconds = 600", + "tool_timeout_seconds = { generate_video = 1800 }", + ].join("\n"); + + const reasonixMcp = new ReasonixMcp({ + outputRoot: testDir, + relativeDirPath: ".", + relativeFilePath: "reasonix.toml", + fileContent, + }); + + const parsed = JSON.parse(reasonixMcp.toRulesyncMcp().getFileContent()); + + expect(parsed.mcpServers.media.call_timeout_seconds).toBe(600); + expect(parsed.mcpServers.media.tool_timeout_seconds).toEqual({ generate_video: 1800 }); + }); + + it("should round-trip both timeout fields through export then import unchanged", async () => { + const rulesyncMcp = new RulesyncMcp({ + outputRoot: testDir, + relativeDirPath: ".rulesync", + relativeFilePath: "mcp.json", + fileContent: JSON.stringify({ + mcpServers: { + media: { + command: "reasonix-plugin-media", + call_timeout_seconds: 300, + tool_timeout_seconds: { generate_video: 1800, transcribe: 120 }, + }, + }, + }), + }); + + const reasonixMcp = await ReasonixMcp.fromRulesyncMcp({ outputRoot: testDir, rulesyncMcp }); + const roundTripped = JSON.parse(reasonixMcp.toRulesyncMcp().getFileContent()); + + expect(roundTripped.mcpServers.media.call_timeout_seconds).toBe(300); + expect(roundTripped.mcpServers.media.tool_timeout_seconds).toEqual({ + generate_video: 1800, + transcribe: 120, + }); + }); + }); });