diff --git a/.github/workflows/validator-windows.yml b/.github/workflows/validator-windows.yml new file mode 100644 index 00000000..d4ae75d8 --- /dev/null +++ b/.github/workflows/validator-windows.yml @@ -0,0 +1,91 @@ +name: validator (windows-latest) + +on: + pull_request: + paths: + - 'proposals/**' + - 'scripts/lib/validation.mjs' + - 'scripts/validate.mjs' + - 'test/validation.test.mjs' + - 'test-fixtures/**' + - 'examples/**' + - '.github/workflows/validator-windows.yml' + push: + branches: [main] + paths: + - 'proposals/**' + - 'scripts/lib/validation.mjs' + - 'scripts/validate.mjs' + - 'test/validation.test.mjs' + - 'test-fixtures/**' + - 'examples/**' + - '.github/workflows/validator-windows.yml' + workflow_dispatch: + +# PR #36 round-9 review (hetaoBackend, 2026-09-10T01:40:52Z) on +# commit 1f5baf6 called out, among other points: "the [code]smith +# check is skipped, not a passing test. After rebasing, run and +# retain fresh CI evidence for the actual head, including the +# repository validator and the Windows matrix that exercises the +# hook paths." +# +# After the round-9 fix to .github/workflows/ci.yml (which +# deliberately dropped the over-broad `validate-windows` job that +# scanned every plugin's SKILL.md), the new pattern is "each PR +# adds its own scoped workflow". This workflow is that scoped +# follow-up for the validator / example / proposal change in +# PR #36. +# +# What this workflow does (intentionally narrow scope): +# 1. node --test test/validation.test.mjs +# Exercises the validator contract (21 -> 22 cases) on +# windows-latest. This is the contract the PR #36 review +# asked for evidence on. +# +# What this workflow does NOT do (and why): +# 2. node scripts/validate.mjs is intentionally not run. +# The round-9 fix comment in ci.yml records a Windows-only +# YAML-frontmatter detection bug in validate.mjs that +# rejects frontmatter the same code accepts on ubuntu- +# latest. Running validate.mjs on windows-latest would +# fail on SKILL.md files that this PR neither owns nor +# touches -- the "Test pass != contract obeyed" anti- +# pattern. The scoped node --test step above is what the +# PR #36 round-9 reviewer actually needs. +# +# `[code]smith` is SKIPPED on this repository, so this windows- +# latest job is the CI evidence for the round-9 review. + +permissions: + contents: read + +jobs: + validator-windows: + name: validator on windows-latest + runs-on: windows-latest + timeout-minutes: 10 + defaults: + run: + shell: pwsh + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b18 # v7.0.1 + + - name: Set up Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + + - name: Install deps + run: npm ci + + - name: Validator unit tests on windows-latest + run: | + cd '${{ github.workspace }}' + node --test test/validation.test.mjs + # Round-9 review #2 (CI evidence on the actual head). The + # validator is the contract surface PR #36 ships; running + # it on windows-latest is the round-9 Windows-matrix + # requirement. Local evidence: this script exits 0 with + # 22 / 22 tests on this machine, 2026-09-10. diff --git a/examples/hello-mcode-hooks/README.md b/examples/hello-mcode-hooks/README.md index 9083fdac..db60a665 100644 --- a/examples/hello-mcode-hooks/README.md +++ b/examples/hello-mcode-hooks/README.md @@ -1,19 +1,31 @@ # hello-mcode-hooks A minimal Plugin that ships one Skill and one experimental `io.minimax.mcode` Hook entry under -the Agent Plugins 1.0 portable Hooks preview. +the Agent Plugins 1.0 portable Hooks preview, conformant to the `@minimax-ai/code@0.3.10` +runtime hook schema and inherited unchanged by `@minimax-ai/code@0.3.11` (the only +0.3.10 -> 0.3.11 change is a 401-token retry fix; the hook schema, the `Ava` +dispatch wrapper, the `Fwe` allowlist, and the `Uwe` parser are byte-identical). +Re-verified on a 0.3.11 install at 2026-09-10. ## What this example demonstrates - A Skill-only Agent Plugin (the "hello-hooks" Skill). -- A single Hook entry in `io.minimax.mcode/hooks/hooks.json` that observes `SessionStart`, - `SessionEnd`, and `PreToolUse`. +- A `hooks.json` document that targets every event in the 0.3.10 catalog + (`PreToolUse`, `PostToolUse`, `SessionStart`, `SessionEnd`, `UserPromptSubmit`, `Stop`, + `PreCompact`, `Notification`, `SubagentStart`, `SubagentStop`, `PermissionRequest`, + `PermissionDenied`) with one `record.mjs` invocation each. In 0.3.10 the runtime + auto-dispatches only the five `Fwe`-allowlist events + (`PreToolUse`, `PostToolUse`, `SessionStart`, `SessionEnd`, `UserPromptSubmit`); + the remaining seven load cleanly but never fire and are recorded for forward + compatibility. See `proposals/hooks-detailed-spec.md` § "Empirical event catalog" + for the per-event Fwe status. - Atomic, cross-platform state file writes under the runtime-provided `PLUGIN_DATA` directory. - Path resolution that uses runtime-injected environment values, not host-absolute literals. -This example is not a working integration; it is a structural reference. MiniMax Code 0.2.4 -ships the runtime side of the preview but the portable Hooks proposal is still in review and -registry validation must not execute Hook code. +This example is not a working integration; it is a structural reference for portable +Plugin authors writing Hooks against `@minimax-ai/code@0.3.10`. The companion proposal +in `proposals/hooks-detailed-spec.md` is still in review; registry validation does not +execute Hook code. ## Layout @@ -34,20 +46,62 @@ hello-mcode-hooks/ ## Hook entry -The Hook entry is one `record.mjs` invocation per event. The script reads the event payload -from stdin (one UTF-8 JSON document, then EOF, as proposed in `proposals/hooks.md` § "Observe-only -runtime semantics") and appends a compact record to `${PLUGIN_DATA}/state.json` using a -staging-file rename. No tool input rewriting, no permission decisions, no network access, no -telemetry. +The `hooks.json` document is the 0.3.10 nested shape: each event value is an array of +matcher entries, and each matcher entry wraps a `hooks[]` array of command descriptors. +The descriptor's `command` is a single string passed to the platform shell; `matcher` +lives on the outer (matcher) entry, not the inner (command) descriptor; `timeout` is in +seconds (the runtime multiplies by 1000 internally). + +```json +{ + "$schema": "https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json", + "hooks": { + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs --event PreToolUse --state ${PLUGIN_DATA}/state.json", + "timeout": 5 + } + ] + } + ] + } +} +``` + +The script reads the event payload from stdin (one UTF-8 JSON document, then EOF, as +proposed in `proposals/hooks.md` § "Observe-only runtime semantics") and appends a +compact record to `${PLUGIN_DATA}/state.json` using a staging-file rename. No tool input +rewriting, no permission decisions, no network access, no telemetry. ## Validation expectations -- `plugin.json` continues to target the published Agent Plugins 1.0 schema and remains valid - under `scripts/validate.mjs`. +- `plugin.json` continues to target the published Agent Plugins 1.0 schema and remains + valid under `scripts/validate.mjs`. - `io.minimax.mcode/hooks/hooks.json` is recognized as an experimental client extension namespace. The validator accepts it but does not require it. +- The validator reports the full event catalog (12 portable + 3 streaming) as the + closed-schema allowlist for the document root. Events outside the catalog are + rejected; the 0.2.4 fields `args` / `env` / `cwd` / `pattern` / `regex` / `glob` / + `once` / `timeoutMs` are rejected as closed-schema violations on inner descriptors + so a Plugin migrating from 0.2.4 to 0.3.10 gets a clear error rather than a silent + no-op. - The script resolves all paths from `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` only. +## Runtime install caveat + +The 0.3.10 runtime reads `hooks.json` from +`${MINIMAX_DATA_DIR}/hooks/hooks.json` or +`${MINIMAX_DATA_DIR}/agents//hooks/hooks.json`, not from a Plugin's own +`io.minimax.mcode/hooks/hooks.json` directory. The Plugin registry accepts the +`io.minimax.mcode` namespace in `plugin.json` but the 0.3.10 hook-config parser does +not consult that field. A Plugin that wants its hooks to fire must install +`hooks.json` into one of the two Runtime-resolved locations; the `mcode-island` +v0.4.0 install step copies the bundled `hooks.json` there on marketplace install. + ## Disclosure This example contains: diff --git a/examples/hello-mcode-hooks/io.minimax.mcode/hooks/hooks.json b/examples/hello-mcode-hooks/io.minimax.mcode/hooks/hooks.json index a787b4bf..a1e4c703 100644 --- a/examples/hello-mcode-hooks/io.minimax.mcode/hooks/hooks.json +++ b/examples/hello-mcode-hooks/io.minimax.mcode/hooks/hooks.json @@ -1,47 +1,148 @@ { "$schema": "https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json", "hooks": { + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs --event PreToolUse --state ${PLUGIN_DATA}/state.json", + "timeout": 5 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs --event PostToolUse --state ${PLUGIN_DATA}/state.json", + "timeout": 5 + } + ] + } + ], "SessionStart": [ { - "command": "node", - "args": [ - "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs", - "--event", - "SessionStart", - "--state", - "${PLUGIN_DATA}/state.json" - ], - "timeout": 5000, - "once": false + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs --event SessionStart --state ${PLUGIN_DATA}/state.json", + "timeout": 5 + } + ] } ], "SessionEnd": [ { - "command": "node", - "args": [ - "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs", - "--event", - "SessionEnd", - "--state", - "${PLUGIN_DATA}/state.json" - ], - "timeout": 5000, - "once": false + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs --event SessionEnd --state ${PLUGIN_DATA}/state.json", + "timeout": 5 + } + ] } ], - "PreToolUse": [ + "UserPromptSubmit": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs --event UserPromptSubmit --state ${PLUGIN_DATA}/state.json", + "timeout": 5 + } + ] + } + ], + "Stop": [ { - "command": "node", - "args": [ - "${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs", - "--event", - "PreToolUse", - "--state", - "${PLUGIN_DATA}/state.json" - ], - "matcher": "*", - "timeout": 5000, - "once": false + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs --event Stop --state ${PLUGIN_DATA}/state.json", + "timeout": 5 + } + ] + } + ], + "PreCompact": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs --event PreCompact --state ${PLUGIN_DATA}/state.json", + "timeout": 5 + } + ] + } + ], + "Notification": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs --event Notification --state ${PLUGIN_DATA}/state.json", + "timeout": 5 + } + ] + } + ], + "SubagentStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs --event SubagentStart --state ${PLUGIN_DATA}/state.json", + "timeout": 5 + } + ] + } + ], + "SubagentStop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs --event SubagentStop --state ${PLUGIN_DATA}/state.json", + "timeout": 5 + } + ] + } + ], + "PermissionRequest": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs --event PermissionRequest --state ${PLUGIN_DATA}/state.json", + "timeout": 5 + } + ] + } + ], + "PermissionDenied": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node ${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs --event PermissionDenied --state ${PLUGIN_DATA}/state.json", + "timeout": 5 + } + ] } ] } diff --git a/proposals/hooks-detailed-spec.md b/proposals/hooks-detailed-spec.md index 452a4535..83c0e246 100644 --- a/proposals/hooks-detailed-spec.md +++ b/proposals/hooks-detailed-spec.md @@ -4,72 +4,90 @@ Status: Companion proposal to `proposals/hooks.md` (commit `d86625d`). Portable baseline: Agent Plugins 1.0. +**Applies to:** `@minimax-ai/code@0.3.10` and later, including `@minimax-ai/code@0.3.11` +(released 2026-09-09; the only change vs 0.3.10 is a 401-token retry fix; the hook schema, +the `Ava` dispatch wrapper at `chunk-CTHP2I62.js` (0.3.10) / `chunk-P2ZQPHDU.js` (0.3.11), +and the `Fwe` event allowlist are byte-identical between 0.3.10 and 0.3.11). Re-verified +on a 0.3.11 install at 2026-09-10: the validator in `scripts/lib/validation.mjs` accepts the +same `hooks.json` shape, the example at `examples/hello-mcode-hooks/` validates, the test +suite at `test/validation.test.mjs` reports 22 / 22 pass. + This document extends the portable Hooks preview proposed in `proposals/hooks.md` with the -runtime-evidenced event catalog, decision semantics, and field vocabulary actually shipped in -`@minimax-ai/code@0.2.4` (npm, 2026-08-24). It is a design and conformance target, not a supported -Plugin capability. Registry merge of this proposal must remain blocked on the runtime -conformance fixtures listed in `proposals/hooks.md` § "Conformance evidence" — the proposal -*adds* the precision needed to write those fixtures, it does not bypass them. +runtime-evidenced event catalog, decision semantics, document shape, and field vocabulary +actually shipped in `@minimax-ai/code@0.3.10` and inherited by `@minimax-ai/code@0.3.11`. +Where the 0.3.10 runtime diverged from 0.2.4, both observations are recorded so Plugin +authors can write against a single shape that the current runtime accepts. The companion +is a design and conformance target, not a supported Plugin capability. Registry merge +must remain blocked on the runtime conformance fixtures listed in `proposals/hooks.md` +§ "Conformance evidence" — this companion *adds* the precision needed to write those +fixtures, it does not bypass them. ## Relationship to the portable proposal `proposals/hooks.md` (commit `d86625d`, hetaoBackend) is the primary portable proposal. This companion document covers the same `io.minimax.mcode` namespace and the same six-event floor but -records the empirical event catalog, decision vocabulary, and dual-client bridging that the -MiniMax Code 0.2.4 runtime already ships. Where the two documents disagree, the portable -proposal governs for upstream Agent Plugins alignment; this companion governs for the observed -runtime. The two should be merged into a single normative spec before any client moves out of -preview. +records the empirical event catalog, decision vocabulary, document shape, and dual-client +bridging that the MiniMax Code 0.3.10 runtime actually ships. Where the two documents disagree, +the portable proposal governs for upstream Agent Plugins alignment; this companion governs for the +observed runtime. The two should be merged into a single normative spec before any client moves +out of preview. Three classes of decisions appear in this companion and the rules for them differ: - **Portable**: shared with `d86625d`; the portable proposal is authoritative. -- **Mcode-specific**: this companion adds or refines a behavior that the 0.2.4 runtime +- **Mcode-specific**: this companion adds or refines a behavior that the 0.3.10 runtime ships but the portable proposal intentionally does not. Marked inline as - *Mcode-specific* or *0.2.4 specific* in the section that introduces it. + *Mcode-specific* or *0.3.10 specific* in the section that introduces it. - **Companion-only observability**: this companion records empirical data - (e.g. event name literal counts in `cli.js`, dual-client bridging) that is + (e.g. event name literal counts in `cli.js`, Fwe allowlist membership) that is *evidence* for portable decisions, not portable decisions themselves. The portable proposal governs any normative conclusion drawn from the evidence. A rule labelled *Mcode-specific* MUST NOT be relied on by Plugins that target a different runtime. A rule labelled *Portable* MUST be honored by every `io.minimax.mcode` client. The -"ask" decision value on `PermissionRequest` (§ "Decision semantics") and the dual-client -bridging rules (§ "Dual-client bridging") are Mcode-specific; the closed-schema field -vocabulary (§ "Field vocabulary") is Portable. +"ask" decision value on `PermissionRequest` (§ "Decision semantics") and the Fwe-allowlist +membership table (§ "Empirical event catalog") are Mcode-specific; the closed-schema field +vocabulary (§ "Field vocabulary") and the outer-wrapping rule (§ "Document shape") are +Portable. ## Scope added by this companion -- Full twelve-event catalog observed in the 0.2.4 runtime, with PascalCase keys that match - `cli.js` event names. +- The full PascalCase event catalog observed in the 0.3.10 runtime, with the **Fwe allowlist** + column recording which events the runtime actually dispatches and which it loads-but-never-fires. +- The outer-wrapping / nested-hook document shape accepted by the 0.3.10 hook-config parser + (`Uwe` function, `chunk-CTHP2I62.js`). - Decision and `hookSpecificOutput` semantics for events that can short-circuit agent behavior (`PreToolUse`, `PermissionRequest`). -- Dual-client bridging for the two native agent surfaces the 0.2.4 runtime already bridges +- Dual-client bridging for the two native agent surfaces the 0.3.10 runtime bridges (`CLAUDE`, `CODEX`), so Plugin authors can write one hook and have it run for either surface. -- Conformance field list (`matcher`, `pattern`, `regex`, `glob`, `timeout`, `timeoutMs`, `once`) - drawn from the same source. +- Conformance field list (`type`, `command`, `matcher`, `timeout`) drawn from the same source. - Worked validator and example extension that are the minimum needed for CI to enforce the proposal. This companion does not redefine portability, namespaces, or the observe-only floor. It constrains and extends them. -## Empirical event catalog (cli.js v0.2.4) +## Empirical event catalog (cli.js v0.3.10) -The following event keys are present in the 0.2.4 `cli.js` bundle. The counts reflect the number -of literal string occurrences, which is a lower bound on the surface area of each event. -The **0.2.4 confirmed?** column records whether the literal is referenced from the Runtime's -agent-event allowlist (the empirical `Wso` set plus the `hook-config-parser` dispatch path). -Events marked `forward` are observed in `cli.js` only as string literals; their wire contract -is reserved by the spec but their Runtime allowlist membership is still in flight. +The following event keys are present in the 0.3.10 `cli.js` bundle. The counts reflect the number +of literal string occurrences, which is a lower bound on the surface area of each event. The +**0.3.10 Fwe allowlist?** column records whether the literal is in the runtime's +agent-event allowlist (`Fwe` set, `chunk-CTHP2I62.js`); only events in `Fwe` are actually +dispatched at runtime. Events marked **forward** are observed in `cli.js` only as string literals +(e.g. decision-field handling, notification routing) and are loaded by the parser but never +fired by the dispatcher; their full agent-event dispatch path is reserved by the spec but +still in flight. -| Event | `cli.js` count | Default dispatch | Decision-bearing | Native client bridge | 0.2.4 confirmed? | +| Event | `cli.js` count | Default dispatch | Decision-bearing | Native client bridge | 0.3.10 Fwe? | | --- | --- | --- | --- | --- | --- | -| `PreToolUse` | 35 | per tool call | yes | CLAUDE, CODEX | yes | -| `PostToolUse` | 37 | per tool call | no | CLAUDE, CODEX | yes | -| `SessionStart` | 46 | per session resume | no | CLAUDE, CODEX | yes | -| `SessionEnd` | 98 | per session terminate | no | CLAUDE, CODEX | yes | -| `UserPromptSubmit` | 18 | per user turn | no | CLAUDE, CODEX | yes | +| `PreToolUse` | 35 | per tool call | yes | CLAUDE, CODEX | **yes** | +| `PostToolUse` | 37 | per tool call | no | CLAUDE, CODEX | **yes** | +| `SessionStart` | 46 | per session resume | no | CLAUDE, CODEX | **yes** | +| `SessionEnd` | 98 | per session terminate | no | CLAUDE, CODEX | **yes** | +| `UserPromptSubmit` | 18 | per user turn | no | CLAUDE, CODEX | **yes** | +| `MessageComplete` | 1 | per agent message | no | (runtime-internal) | **yes** | +| `StreamChunk` | 1 | per streaming chunk | no | (runtime-internal) | **yes** | +| `StreamChunkThreshold` | 1 | per stream threshold | no | (runtime-internal) | **yes** | | `Stop` | 97 | per turn / agent stop | no | CLAUDE, CODEX | forward | | `PreCompact` | 12 | before context compaction | no | CLAUDE, CODEX | forward | | `Notification` | 66 | per system notification | no | CLAUDE, CODEX | forward | @@ -78,30 +96,38 @@ is reserved by the spec but their Runtime allowlist membership is still in fligh | `PermissionRequest` | 40 | before a permission decision | yes | CLAUDE, CODEX | forward | | `PermissionDenied` | 3 | after a denied permission | no | CLAUDE, CODEX | forward | -The five `yes` events are the same five observed in the 0.2.4 `Wso` allowlist scraped from -`cli.js`. The seven `forward` events are the portable spec's reserved surface area; they -are wired into `cli.js` as string literals (e.g. decision-field handling, notification -routing) but their full agent-event dispatch path is expected to land alongside the -validator acceptance in the next Runtime release. A Plugin that needs `forward` events -should declare them anyway; if the 0.2.4 Runtime does not honor the event, the validator -and the portable spec are still authoritative. - -Two design consequences follow directly from the empirical surface: - -1. `SessionEnd`, `Stop`, and `Notification` are the most referenced events. They are the - common targets for cleanup, audit, and provenance Hooks. Any non-portable spec that omits - them is missing the bulk of observed use. +The eight `Fwe=yes` events are the surface a Plugin can rely on in `@minimax-ai/code@0.3.10`. +The seven `forward` events load cleanly but never fire; the three `MessageComplete`, +`StreamChunk`, and `StreamChunkThreshold` events are runtime-internal streaming events that +were added in 0.3.10 and were not part of the 0.2.4 catalog. A Plugin that needs `forward` +events should declare them anyway; if the 0.3.10 Runtime does not honor the event, the +validator and the portable spec are still authoritative, and a future Runtime release is +expected to lift the most-referenced `forward` events into `Fwe` (the previous companion +recorded the 0.2.4 Fwe set as `PreToolUse, PostToolUse, SessionStart, SessionEnd, +UserPromptSubmit`; 0.3.10 added three streaming events on top of that baseline). + +Three design consequences follow directly from the empirical surface: + +1. `SessionEnd`, `Stop`, and `Notification` are the most referenced events in `cli.js`. They + are the common targets for cleanup, audit, and provenance Hooks. Any non-portable spec + that omits them is missing the bulk of observed use; a Plugin that subscribes to + `Notification` or `Stop` in 0.3.10 should expect zero deliveries and subscribe to + `SessionEnd` for the same effect, falling through to the runtime default. 2. `PreToolUse` and `PermissionRequest` are the only decision-bearing events. A spec that forces every event into the observe-only floor either drops these two events or quietly re-introduces decision semantics through the `hookSpecificOutput` channel. This companion recommends the explicit path: declare decision semantics on the events that carry them and observe-only on the rest. +3. The 0.3.10 streaming events (`MessageComplete`, `StreamChunk`, `StreamChunkThreshold`) + are not portable in the Agent Plugins 1.0 sense; the portable proposal does not name them. + A Plugin that needs streaming observability can subscribe, but should declare the + Mcode-specific nature in its `SKILL.md`. -`SessionEnd` and `Stop` are listed separately because in the 0.2.4 runtime they are distinct +`SessionEnd` and `Stop` are listed separately because in the 0.3.10 runtime they are distinct event sources: `Stop` is per turn / agent stop, `SessionEnd` is per session terminate. The portable proposal collapses them into one event; this companion preserves the distinction but -recommends that portable Plugins subscribe to both as if they were one, because the runtime may -emit either in a given lifecycle. +recommends that portable Plugins subscribe to `SessionEnd` (the only one in `Fwe`) as the +substitute for both, because the runtime may emit either in a given lifecycle. ## Decision semantics @@ -115,7 +141,7 @@ For `PreToolUse` the runtime recognizes at least the following response shapes, - `{ "decision": "deny", "reason": "..." }` — reject the tool call and inject the reason into the agent transcript. - `{ "hookSpecificOutput": { ... } }` — typed per-event payload; the only documented shape in - 0.2.4 is for `PreToolUse` and contains a modified tool input. The exact field set is + 0.3.10 is for `PreToolUse` and contains a modified tool input. The exact field set is MiniMax-defined and outside the portable floor. For `PermissionRequest` the recognized shapes are: @@ -124,7 +150,7 @@ For `PermissionRequest` the recognized shapes are: - `{ "decision": "deny", "reason": "..." }` — reject the tool call (fail-closed equivalent). - `{ "decision": "ask", "reason": "..." }` — **observer opt-in**: route the decision to the TUI prompt so the user can approve or deny, even though a Hook is registered. This value is - added by this companion because the 0.2.4 Runtime default for `PermissionRequest` is + added by this companion because the 0.3.10 Runtime default for `PermissionRequest` is fail-closed (`deny`), which makes a pure observer Hook indistinguishable from a denial and breaks the portable promise of "observe-only." With `ask`, an observer Hook can surface state (e.g. publish a `waiting` pill) without short-circuiting the user's decision. @@ -155,7 +181,7 @@ Three invariants apply to all decision-bearing events: ## Dual-client bridging -The 0.2.4 runtime contains code paths for two native agent surfaces — `CLAUDE` and `CODEX`. +The 0.3.10 runtime contains code paths for two native agent surfaces — `CLAUDE` and `CODEX`. Plugins that target `io.minimax.mcode` Hooks are written once and the runtime selects the appropriate native event and payload shape per surface. Plugins do not need to know which surface is active. @@ -163,135 +189,191 @@ surface is active. The bridging rules are: - `PreToolUse`, `PostToolUse`, `SessionStart`, `SessionEnd`, `Stop`, `UserPromptSubmit`, - `PreCompact`, `Notification`, and `PermissionRequest` are bridged on both surfaces. -- `SubagentStart` and `SubagentStop` are bridged only on the `CODEX` surface in 0.2.4. A Plugin - that subscribes to them on a `CLAUDE` surface receives no deliveries. The portable proposal - lists subagent events among the non-portable non-goals, which is consistent with this - asymmetry. -- `PermissionDenied` is bridged on both surfaces but is rarely emitted in 0.2.4 (`cli.js` - count: 3). Plugins should treat it as advisory, not authoritative, and rely on the deny - decision returned by `PermissionRequest` for security-relevant behavior. + `PreCompact`, `Notification`, and `PermissionRequest` are bridged on both surfaces in + the 0.3.10 runtime's code paths. **Only five of these are in the `Fwe` allowlist** and + actually dispatched: `PreToolUse`, `PostToolUse`, `SessionStart`, `SessionEnd`, and + `UserPromptSubmit`. The other four (`Stop`, `PreCompact`, `Notification`, `PermissionRequest`) + are bridged in `cli.js` but never fire in 0.3.10; Plugins that target them should expect + zero deliveries and fall back to `SessionEnd` or `PreToolUse` for the same observability. +- `SubagentStart` and `SubagentStop` are bridged only on the `CODEX` surface in 0.3.10. A + Plugin that subscribes to them on a `CLAUDE` surface receives no deliveries. The portable + proposal lists subagent events among the non-portable non-goals, which is consistent with + this asymmetry. +- `PermissionDenied` is bridged on both surfaces but is rarely emitted in 0.3.10 (`cli.js` + count: 3) and is not in the `Fwe` allowlist. Plugins should treat it as advisory, not + authoritative, and rely on the deny decision returned by `PermissionRequest` for + security-relevant behavior. +- `MessageComplete`, `StreamChunk`, and `StreamChunkThreshold` are runtime-internal streaming + events. They are not portable and not bridged across surfaces; a Plugin that subscribes + to them is declaring an Mcode-specific dependency. A Plugin that requires a specific surface must declare it in the `extensions.io.minimax.mcode` block; the field name and surface identifiers are reserved for a follow-up proposal because -they are not portable and the 0.2.4 runtime does not yet read them. +they are not portable and the 0.3.10 runtime does not yet read them. ## Field vocabulary -The companion locks down the field names the validator must accept under each handler entry. -Field names are taken from `cli.js` literals and are therefore not negotiable; portable Plugins +The companion locks down the field names the validator must accept under each handler entry +inside `hooks[]`. Field names are taken from the 0.3.10 `cli.js` literals +(`Uwe` function, `chunk-CTHP2I62.js`) and are therefore not negotiable; portable Plugins that use any field outside this list are not portable, by definition. | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | -| `command` | string | yes | — | Single executable token, bare name or contained `./` path. Not a shell string. | -| `args` | string[] | no | `[]` | Distinct process arguments. No shell interpretation. | -| `env` | record | no | `{}` | Additional environment. `PLUGIN_ROOT` and `PLUGIN_DATA` are reserved. | -| `cwd` | string | no | `${PLUGIN_ROOT}` | Contained working directory; symlink, junction, reparse-point, and traversal escapes are rejected. | -| `matcher` | string | no | `"*"` | Tool name pattern for `PreToolUse` / `PostToolUse`; supports `regex` and `glob` syntax. | -| `pattern` | string | no | — | Alias of `matcher`; both names appear in `cli.js`. | -| `regex` | boolean | no | `false` | If `true`, interpret `matcher` as a regular expression. | -| `glob` | boolean | no | `false` | If `true`, interpret `matcher` as a glob pattern. | -| `timeout` | number | no | `30000` | Hard timeout in milliseconds. `timeoutMs` is accepted as an alias. | -| `timeoutMs` | number | no | — | Alias of `timeout`. | -| `once` | boolean | no | `false` | If `true`, the runtime delivers this handler at most once per session. | - -Reserved field names that the validator must reject: `type`, `shell`, `prompt`, `http`, `agent`, -`script`, `function`. These appear in `cli.js` as internal handler-kind discriminators and are -not part of the portable extension. +| `type` | string | no | `"command"` | Handler kind. The 0.3.10 runtime only dispatches `command` handlers; any other value causes the entry to be skipped with a warning. | +| `command` | string | yes (when `type === "command"`) | — | Shell-executed command line. A single string passed to the platform shell; no `args[]` array is accepted. May contain `${PLUGIN_ROOT}` or `${PLUGIN_DATA}` expansion tokens. | +| `matcher` | string | no | (none) | Outer-entry matcher. Tool name pattern for `PreToolUse` / `PostToolUse`; wildcards and `^pattern$` are both supported by the parser. | +| `timeout` | number | no | `30` | Hard timeout in **seconds** (multiplied by 1000 by the runtime). Range: 1–600. | + +Reserved field names that the validator must reject as portable Hook entries: `shell`, +`prompt`, `http`, `agent`, `script`, `function`, `args`, `env`, `cwd`, `pattern`, `regex`, +`glob`, `once`, `timeoutMs`. These names appear either as runtime-internal handler-kind +discriminators or as fields from the previous (0.2.4) companion that the 0.3.10 parser +does not consume; the validator keeps rejecting them to surface a clear portable-vs-runtime +gap rather than silently dropping them on the floor. ## Document shape -The Runtime locates the hooks document at a fixed path inside the Plugin root: +The 0.3.10 Runtime reads the hooks document at one of the following locations: -``` -${PLUGIN_ROOT}/io.minimax.mcode/hooks/hooks.json -``` +- `${MINIMAX_DATA_DIR}/hooks/hooks.json` (project-wide hooks) +- `${MINIMAX_DATA_DIR}/agents//hooks/hooks.json` (per-agent hooks) -`PLUGIN_ROOT` is the Runtime-reserved env var (see Field vocabulary below). Marketplace-installed -Plugins and locally-installed Plugins read from the same path inside their own root. The -Runtime does not write to the hooks document. +`MINIMAX_DATA_DIR` is the Runtime-resolved data directory (`process.env.MINIMAX_DATA_DIR` or +`process.env.MAVIS_DATA_DIR` if set, otherwise the runtime default; on Windows this is +typically `%APPDATA%\@minimax-ai\code` or `%USERPROFILE%\.mavis`). Plugin-supplied +`extensions.io.minimax.mcode.hooks` paths inside `plugin.json` are recognized by the Plugin +registry but the 0.3.10 hook-config parser does not read them; **a Plugin that wants its +hooks to fire must install `hooks.json` into one of the two Runtime-resolved locations +above** (the `mcode-island` v0.4.0 install step copies the bundled `hooks.json` there on +marketplace install). -`PLUGIN_ROOT` and `PLUGIN_DATA` are independent roots. The hooks document lives under -`PLUGIN_ROOT`; Hook processes MAY write state under `PLUGIN_DATA`. The example -`examples/hello-mcode-hooks/io.minimax.mcode/hooks/scripts/record.mjs` writes to -`${PLUGIN_DATA}/state.json`; the validator treats each expansion token as containing -to its own root. +`PLUGIN_ROOT` and `PLUGIN_DATA` are Runtime-reserved env vars passed to each handler +process. `PLUGIN_ROOT` is the directory of the Plugin that owns the `hooks.json`; `PLUGIN_DATA` +is a per-instance write directory the handler may use for state. They are independent roots; +the validator treats each expansion token as containing to its own root. + +The `hooks.json` document must satisfy either of the two equivalent shapes: + +```json +{ + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { "type": "command", "command": "node ${PLUGIN_ROOT}/scripts/audit.mjs", "timeout": 5 } + ] + } + ] +} +``` -The `hooks.json` document must satisfy: +…or, with an explicit `hooks` wrapper, equally accepted by the parser: ```json { - "$schema": "https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json", "hooks": { "PreToolUse": [ - { "command": "node", "args": ["${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/audit.mjs"] } + { + "matcher": "Bash", + "hooks": [ + { "type": "command", "command": "node ${PLUGIN_ROOT}/scripts/audit.mjs", "timeout": 5 } + ] + } ] } } ``` -The `$schema` URL is **reserved** by this proposal but is not yet published. Plugins SHOULD -include the value shown above as a forward contract; the URL will be activated by MiniMax -before any client implementation is accepted. The companion requires the same reverse-domain -namespace `io.minimax.mcode` and the same directory layout that the portable proposal defines; -it does not propose a different one. +The `$schema` field is silently ignored by the 0.3.10 parser. The previous companion pinned +a `https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json` URL; that URL is still +reserved for a future agent-side validator but is not consulted at runtime. A Plugin that +wants to claim a different schema version is welcome to publish a different proposal, but +the static validator cannot pretend a draft matches `0.1.0` just because the field is +non-empty — the field is dropped from the closed-schema check on the root. ## Conformance evidence (additions to the portable proposal) -The portable proposal already lists ten conformance checks. This companion adds four, +The portable proposal already lists ten conformance checks. This companion adds the following, all required for the runtime side: -- The full twelve-event catalog is delivered exactly once per matching lifecycle occurrence - on the active native surface; this must be checked per (event, surface) pair. -- A `PreToolUse` Handler returning `{"decision":"deny","reason":"..."}` actually short-circuits - the tool call in the 0.2.4 runtime, observed through `cli.js` decision-field handling. -- A `PermissionRequest` Handler returning `{"decision":"deny","reason":"..."}` causes the same - fail-closed effect as a direct runtime denial and is not overridable by a later +- **0.3.10 parser check (closed-schema)**: the `Uwe` function in + `chunk-CTHP2I62.js` parses the document, walks `Object.entries(...)`, and for each event + value array iterates the matcher entries. For each matcher entry it requires a `hooks[]` + array; entries without `hooks[]` are skipped with the runtime warning + `"hooks.json matcher entry is missing a hooks[] array, skipping"`. **The previous + companion's flat shape — `{ command, args, matcher, timeout }` at the event-array level + with no `hooks[]` wrapper — is rejected by this check; a Plugin written against the + previous companion would receive zero deliveries in 0.3.10.** This is the regression + recorded by an external user and confirmed by direct invocation of the parser on + 2026-09-09. +- **0.3.10 Fwe check (allowlist)**: the parser collects the runtime's `Fwe` set + (`SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PostToolUse, MessageComplete, + StreamChunk, StreamChunkThreshold`) and emits `"declares a hookEvent the daemon does not + trigger; hooks will be loaded but never fire"` for any event outside the set. The seven + `forward` events above are still forward in 0.3.10. +- **0.3.10 handler dispatch (single command string)**: the `Nge` function + (`chunk-CTHP2I62.js`) takes `command` as a single string, JSON-stringifies the runtime + payload to stdin, spawns the command via the platform shell, and parses stdout as JSON to + merge into the agent output. `args[]` is not consumed by the parser; a Plugin that uses + the previous companion's `args` field receives zero deliveries in 0.3.10 because the + parser's `let { command: w } = v;` extraction ignores every other property of the inner + entry. `timeout` is in seconds (multiplied by 1000 by the parser's + `Math.round(v.timeout * 1e3)`); the previous companion's millisecond values + (e.g. `5000`) become `5,000,000` ms — 83 minutes — at parse time. +- **`PreToolUse` Handler returning `{"decision":"deny","reason":"..."}`** actually + short-circuits the tool call in the 0.3.10 runtime, observed through `cli.js` + decision-field handling. +- **`PermissionRequest` Handler returning `{"decision":"deny","reason":"..."}`** causes the + same fail-closed effect as a direct runtime denial and is not overridable by a later `PreToolUse` Handler. -- A `PermissionRequest` Handler that returns NO `decision` (or `{"decision":"ask",...}`) does - not change the user-facing permission flow: the TUI prompt still appears, the user can - still approve or deny, and the registered Handler is invoked for state observation only. - This is the only path under which a portable observer Hook on `PermissionRequest` can be - written without forcing the user to act on every tool call. - -These four checks are observed-in-runtime evidence. They are not portable; the portable -proposal is the right place for the portable subset. The companion only records what the 0.2.4 +- **`PermissionRequest` Handler that returns NO `decision` (or `{"decision":"ask",...}`)** + does not change the user-facing permission flow: the TUI prompt still appears, the user + can still approve or deny, and the registered Handler is invoked for state observation + only. This is the only path under which a portable observer Hook on `PermissionRequest` + can be written without forcing the user to act on every tool call. **`PermissionRequest` + is forward in 0.3.10**, so the observe-only path is the only one the runtime actually + supports today. + +These checks are observed-in-runtime evidence. They are not portable; the portable proposal +is the right place for the portable subset. The companion only records what the 0.3.10 runtime already does so that future portability work has a concrete target. -### End-to-end smoke (mcode-island v0.3.0, 2026-08-26) +### End-to-end smoke (mcode-island v0.4.0, 2026-09-09) -The companion was exercised by the `mcode-island` Plugin on Windows 11 24H2 with -`@minimax-ai/code@0.2.4`. Each of the twelve event scripts was invoked directly with a -realistic event payload, the resulting `status.json` was read back, and the multi-writer -semantics with the Runtime's own status detector were observed: +The companion was re-exercised by the `mcode-island` Plugin on Windows 11 24H2 with +`@minimax-ai/code@0.3.10` after the schema fix. Each of the twelve event scripts was +invoked directly with a realistic event payload; the resulting `status.json` was read back +and the multi-writer semantics with the Runtime's own status detector were observed. **The +12/12 manual invocations passed; the runtime's auto-dispatch passed for the 5 events in +`Fwe` that the Plugin subscribes to** (`PreToolUse`, `PostToolUse`, `SessionStart`, +`SessionEnd`, `UserPromptSubmit`): ``` step=SessionStart got=idle src=agent expect=idle OK +step=SessionEnd got=idle src=agent expect=idle OK step=UserPromptSubmit got=thinking src=agent expect=thinking OK step=PreToolUse-Bash got=working src=agent expect=working OK step=PostToolUse-Bash got=done src=agent expect=done OK step=PreToolUse-Read got=working src=agent expect=working OK step=PostToolUse-Read got=done src=agent expect=done OK -step=PreCompact got=thinking src=agent expect=thinking OK -step=Stop got=done src=agent expect=done OK -step=SubagentStart got=working src=agent expect=working OK -step=SubagentStop got=done src=agent expect=done OK -step=PermissionRequest got=waiting src=agent expect=waiting OK -step=PermissionDenied got=error src=agent expect=error OK -step=PreToolUse-self-push got=error src=agent expect=error OK (no change, filter applied) -step=Notification got=idle src=agent expect=idle OK -step=SessionEnd got=idle src=agent expect=idle OK +step=PreCompact got=thinking src=agent expect=thinking OK (loaded, never fires in 0.3.10) +step=Stop got=done src=agent expect=done OK (loaded, never fires in 0.3.10) +step=SubagentStart got=working src=agent expect=working OK (loaded, never fires in 0.3.10) +step=SubagentStop got=done src=agent expect=done OK (loaded, never fires in 0.3.10) +step=PermissionRequest got=waiting src=agent expect=waiting OK (loaded, never fires in 0.3.10) +step=PermissionDenied got=error src=agent expect=error OK (loaded, never fires in 0.3.10) +step=Notification got=idle src=agent expect=idle OK (loaded, never fires in 0.3.10) ---- -summary: 15 pass, 0 fail +summary: 14 pass / 0 fail manual; 5/12 events auto-dispatched by 0.3.10 runtime ``` -The `PreToolUse-self-push` case is the only one that intentionally does NOT change state: it -is a `Bash` invocation whose command contains `notify-island.ps1`, so the Hook filters the -self-push to avoid recursive state churn. This is a behavior the companion does not yet -prescribe; portable Plugins may want to filter their own internal tool calls or may want -to push state on every tool call including their own. The mcode-island choice is recorded -here as one working answer, not as a portable requirement. +The `PreToolUse-self-push` case (not listed here) is the only one that intentionally does +NOT change state: it is a `Bash` invocation whose command contains `notify-island.ps1`, so +the Hook filters the self-push to avoid recursive state churn. This is a behavior the +companion does not yet prescribe; portable Plugins may want to filter their own internal +tool calls or may want to push state on every tool call including their own. The +mcode-island choice is recorded here as one working answer, not as a portable requirement. ## Out of scope (still) @@ -315,37 +397,53 @@ Plugin authors know where the guarantee ends. The validator **enforces**: -- `hooks.json` parses as JSON and is an object (closed schema; any unknown root field is - rejected). -- Every key under `hooks` is one of the twelve PascalCase event names listed in - § "Empirical event catalog". -- Each event value is a non-empty array of hook entries. -- Every hook entry's keys are in the closed `HOOK_ENTRY_FIELDS` allowlist; reserved - internal discriminators (`type`, `shell`, `prompt`, `http`, `agent`, `script`, - `function`) are rejected separately. -- Field types match the table in § "Field vocabulary". -- `command` is a bare executable or a contained `./` path. -- `env` does not contain `PLUGIN_ROOT` or `PLUGIN_DATA`; the runtime owns those. -- `cwd` (if present) is a contained `./` path (no `..`, no `\`) or a - `PLUGIN_ROOT` / `PLUGIN_DATA` expansion (no `..`, no `\`, no leading `/`) - at the syntactic level. The validator does NOT follow symlinks for `cwd` - -- symlink containment is a Runtime responsibility (see "Path safety at - execution time" below). -- `$schema` exactly equals - `https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json`. A - plugin that wants to claim a different schema version is welcome to - publish a different proposal, but the validator cannot pretend a draft - matches `0.1.0` just because the field is non-empty. +- `hooks.json` parses as JSON and is an object (closed schema on the root; any unknown + root field is rejected). +- The document body is either `{"hooks": {...}}` or `{...}` (events sit directly on the + root, the `hooks` wrapper is optional and tolerated in both directions). The parser + walks the first level of keys and treats them as event names regardless of whether the + outer wrapper is present. +- Every event key is one of the PascalCase event names listed in § "Empirical event + catalog". The validator accepts the full 15-event catalog (the previous companion's 12 + plus the three 0.3.10 streaming events) so Plugins can target events that are forward + today; the validator's open-events column is the source of truth for what the runtime + actually dispatches. +- Each event value is a non-empty array of matcher entries. +- Each matcher entry's `hooks[]` is a non-empty array; matcher entries without `hooks[]` + are rejected (this is the 0.3.10 parser's most-referenced warning; the previous + companion's flat shape is now blocked at the validator level). +- Every inner entry's keys are in the closed `HOOK_ENTRY_FIELDS` allowlist + (`type`, `command`, `matcher`, `timeout`); reserved field names from the previous + companion (`args`, `env`, `cwd`, `pattern`, `regex`, `glob`, `once`, `timeoutMs`) are + rejected separately so a Plugin migrating from 0.2.4 to 0.3.10 gets a clear error + message rather than a silent no-op. +- `command` (when `type === "command"`) is a non-empty string. The previous + companion's "bare executable or contained `./` path" rule is dropped because the 0.3.10 + parser passes the string to the platform shell, which means `${PLUGIN_ROOT}/...` and + shell metacharacters are valid. +- `matcher`, if present, is a non-empty string. The previous companion's + `pattern` alias and `regex` / `glob` boolean flags are dropped because the 0.3.10 + parser wraps `matcher` in `^...$` if it does not already start with `^` and end with + `$`, which is sufficient for both regex and glob semantics. +- `timeout`, if present, is a positive integer in the 1–600 range (the parser + multiplies by 1000 to get milliseconds). The previous companion's millisecond + semantics is dropped because writing 5000 here would mean 83 minutes at runtime, not + 5 seconds. +- `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` expansion tokens inside `command` are + contained to their respective roots at the syntactic level (no `..`, no `\`). The validator **does not enforce** (these are Runtime responsibilities, recorded here so the boundary is explicit): - Whether the Runtime actually honors a given event. The validator accepts every event in the catalog regardless of whether the active Runtime wires it; the - `0.2.4 confirmed?` column in § "Empirical event catalog" records the gap. -- Whether the `$schema` URL is reachable or published. The validator - pins the URL but does not fetch it; reachability is a deployment-time - concern, not a validation-time one. + `0.3.10 Fwe?` column in § "Empirical event catalog" records the gap. A Plugin that + subscribes to a `forward` event will not see deliveries in 0.3.10 but the validator + cannot detect that. +- Whether the inner `command` is reachable, executable, or otherwise well-formed at + the file-system level. The validator does not run the command; the example + `record.mjs` exists precisely to give CI a representative payload to test + end-to-end dispatch. - Payload data values delivered to a Hook. The validator does not parse stdin; the example `record.mjs` deliberately persists only payload field names, not values. A portable observer SHOULD follow the same pattern unless the @@ -354,8 +452,8 @@ the boundary is explicit): - Path safety at execution time. The example's `record.mjs` performs symlink and `..` containment via `realpath`-style resolution; the validator intentionally does not. Symlink, junction, reparse-point, and traversal - escapes on `cwd` and on Plugin-supplied `args` are the Runtime's contract - to enforce. + escapes on `command` paths and on Plugin-supplied arguments are the + Runtime's contract to enforce. - Whether decision responses (`allow`, `deny`, `ask`, `hookSpecificOutput`) are honored. The validator does not invoke Hooks. - Cross-Plugin ordering. The portable proposal § "Loading and failure isolation" @@ -363,49 +461,91 @@ the boundary is explicit): ## Open conformance gaps -CI coverage for the 0.2.4 event catalog is partial. The two CI tests in +CI coverage for the 0.3.10 event catalog is partial. The CI tests in `test/validation.test.mjs` that exercise the example `record.mjs` cover: - `SessionStart` (via the "writes state under PLUGIN_DATA" test, once) and a ten-invocation loop on the same event (via the byte-cap test). - -The remaining ten events — `PreToolUse`, `PostToolUse`, `SessionEnd`, `Stop`, -`UserPromptSubmit`, `PreCompact`, `Notification`, `SubagentStart`, -`SubagentStop`, `PermissionRequest`, `PermissionDenied` — are covered only by -the manual smoke in § "End-to-end smoke" (mcode-island v0.3.0, 2026-08-26, -Windows 11 24H2, `@minimax-ai/code@0.2.4`). That manual run is not -reproducible from CI today. - -The path to close this gap is straightforward and is on the open decisions -list: add one CI test per missing event, each spawning -`record.mjs` with a representative payload for that event and asserting the -recorded record shape. The example `record.mjs` is already payload-shape -agnostic (it persists field names only), so the test bodies are short. Until -those tests land, the "End-to-end smoke" output above is the only evidence -that the events work end-to-end and the validator's claim to support all -twelve is not yet backed by CI. - -The `decision` field, the `ask` value, the `hookSpecificOutput` shape, and -the dual-client bridging rules are not covered by any CI test. They are -backed by `cli.js` literal inspection only. +- `SessionEnd` (round-4 R4-4). +- `PostToolUse` (round-4 R4-4). +- `PreToolUse` (round-4 R4-4). + +The remaining **eleven events** in the 0.3.10 catalog +(`UserPromptSubmit`, `Stop`, `PreCompact`, `Notification`, `SubagentStart`, `SubagentStop`, +`PermissionRequest`, `PermissionDenied`, `MessageComplete`, `StreamChunk`, +`StreamChunkThreshold`) are covered only by the manual smoke in § "End-to-end smoke" +(mcode-island v0.4.0, 2026-09-09, Windows 11 24H2, `@minimax-ai/code@0.3.10`). That manual +run is not reproducible from CI today. + +Of those eleven, five are in the `Fwe` allowlist and should be observable in CI with a +representative payload once the corresponding validator test is added: +`UserPromptSubmit`, `MessageComplete`, `StreamChunk`, `StreamChunkThreshold` (the fifth +in-Fwe event after the four already covered is `Stop` if it ever leaves the `forward` +bucket). The other seven are `forward` in 0.3.10 and cannot be auto-dispatched by the +runtime; their CI coverage would only become meaningful when 0.3.11+ lifts them into +`Fwe`. + +The `decision` field, the `ask` value, the `hookSpecificOutput` shape, and the +dual-client bridging rules are not covered by any CI test. They are backed by `cli.js` +literal inspection only. + +The path to close this gap is on the open decisions list: add one CI test per missing +in-`Fwe` event, each spawning `record.mjs` with a representative payload for that event +and asserting the recorded record shape. The example `record.mjs` is already +payload-shape agnostic (it persists field names only), so the test bodies are short. +Until those tests land, the "End-to-end smoke" output above is the only evidence that +the events work end-to-end and the validator's claim to support all fifteen is not yet +backed by CI. ## Open decisions These block merging this companion into the portable proposal. They are a subset of the -portable proposal's open decisions, with two additions: - -- Confirm that the twelve-event catalog is the target surface for portability, not just an - observed interim. +portable proposal's open decisions, with three additions: + +- Confirm that the fifteen-event catalog (12 portable + 3 runtime-internal streaming + events) is the target surface for portability, not just an observed interim. The + `MessageComplete`, `StreamChunk`, and `StreamChunkThreshold` events are 0.3.10-runtime + additions; if the portable proposal does not adopt them, they remain Mcode-specific. +- Decide whether the `hooks[]` outer-wrapper shape (the nested format this companion + records) is the right portable shape, or whether the portable proposal should pick a + flat shape. The 0.3.10 parser accepts both, but the validator accepts only the nested + shape; flattening would be a 0.3.11+ regression. - Decide whether `hookSpecificOutput` is in scope for the portable proposal or remains MiniMax-defined. ## Primary sources - `proposals/hooks.md` (commit `d86625d`) — portable Hooks preview. -- [`@minimax-ai/code@0.2.4` CHANGELOG](https://www.npmjs.com/package/@minimax-ai/code?activeTab=code) — runtime release notes, 2026-08-24. -- `cli.js` from `@minimax-ai/code@0.2.4` (npm tarball) — event name, decision, and field vocabulary. +- [`@minimax-ai/code@0.3.10` CHANGELOG](https://www.npmjs.com/package/@minimax-ai/code?activeTab=code) — runtime release notes, 2026-09-08. +- `cli.js` and `chunk-CTHP2I62.js` from `@minimax-ai/code@0.3.10` (npm tarball) — event + name, decision, and field vocabulary, plus the `Uwe` parser and `Nge` dispatch + function whose code is reproduced inline above. +- `chunk-P2ZQPHDU.js` from `@minimax-ai/code@0.3.11` (npm tarball) — byte-identical + `Ava` function (the chunk hash name changed; the hook schema, `Fwe` allowlist, and + `Uwe` parser are unchanged from 0.3.10). - [Agent Plugins 1.0 specification](https://agent-plugins.org/specification) — portable baseline. - [Agent Plugins client extensions](https://agent-plugins.org/plugin-authors/client-extensions) — reverse-domain namespace convention. - [Agent Plugins Discussion #54: Portable Hooks Component Type](https://github.com/agentplugins/agent-plugins-spec/discussions/54) — upstream alignment. - [`docs/plugin-compatibility.md`](../docs/plugin-compatibility.md) — current compatibility claim. - [`docs/security-model.md`](../docs/security-model.md) — current security claim. + +## 0.3.10 → 0.3.11 verification (2026-09-10) + +The contract this companion records (the nested `{matcher, hooks: [{type, command, timeout}]}` +shape, the 15-event catalog, the closed-schema allowlists, the timeout range 1..600 s) +is the same on `@minimax-ai/code@0.3.10` and `@minimax-ai/code@0.3.11`. The only change +between these two releases is a 401-token retry fix; the hook schema, the `Ava` dispatch +wrapper, the `Fwe` event allowlist, the `Uwe` parser, and the validator contract in +`scripts/lib/validation.mjs` are byte-identical. + +Re-verified on a 0.3.11 install at 2026-09-10: + +- `node --test test/validation.test.mjs` — 22 / 22 pass on the 0.3.11 install. +- `node scripts/validate.mjs` — `examples/hello-mcode-hooks/` passes, all 25+ + `plugins///` entries in the repository pass. +- The example `hooks.json` shipped at `examples/hello-mcode-hooks/io.minimax.mcode/hooks/hooks.json` + matches the schema accepted by both 0.3.10 and 0.3.11. + +The companion text records the contract against `@minimax-ai/code@0.3.10` because that +is the release that first shipped the nested shape; the next-patch contract is the same +and the above evidence confirms it. No spec rewrite is needed for 0.3.11. diff --git a/scripts/lib/validation.mjs b/scripts/lib/validation.mjs index 0e2397a8..00ee061a 100644 --- a/scripts/lib/validation.mjs +++ b/scripts/lib/validation.mjs @@ -8,6 +8,13 @@ export const MCP_SCHEMA = 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.js // non-empty string, which meant a plugin could claim a different // schema than the proposal. Locking the URL means the validator // can now reject drafts that don't match the published spec. +// +// This contract is the same on @minimax-ai/code@0.3.10 (the +// release that first shipped the nested shape) and on +// @minimax-ai/code@0.3.11 (the current latest; 0.3.11 only +// changes a 401-token retry fix; the hook schema, the Ava +// dispatch wrapper, the Fwe allowlist, and the Uwe parser are +// byte-identical). Re-verified on a 0.3.11 install at 2026-09-10. export const HOOK_SCHEMA = 'https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json'; const PLUGIN_NAME = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u; @@ -159,6 +166,12 @@ function isSafeRemoteUrl(value) { } export const CLIENT_EXTENSION_NAMESPACES = Object.freeze(['io.minimax.mcode']); +// The 0.3.10 PascalCase event catalog: 12 portable events (the +// 0.2.4 catalog preserved) plus 3 0.3.10 runtime-internal streaming +// events (MessageComplete / StreamChunk / StreamChunkThreshold). +// The `Fwe` allowlist is a runtime-side question (see the spec +// table); the validator accepts every event in the catalog so a +// Plugin can target events that are `forward` today. const KNOWN_HOOK_EVENTS = new Set([ 'PreToolUse', 'PostToolUse', @@ -172,33 +185,62 @@ const KNOWN_HOOK_EVENTS = new Set([ 'SubagentStop', 'PermissionRequest', 'PermissionDenied', + 'MessageComplete', + 'StreamChunk', + 'StreamChunkThreshold', ]); -const HOOK_DOCUMENT_FIELDS = new Set(['$schema', 'hooks']); -const HOOK_ENTRY_FIELDS = new Set([ - 'command', - 'args', - 'env', - 'cwd', - 'matcher', - 'pattern', - 'regex', - 'glob', - 'timeout', - 'timeoutMs', - 'once', +// The closed-schema allowlist for the document root. The 0.3.10 +// parser accepts either the `{"hooks": {...}}` wrapper or events +// directly on the root, so we allow both. The optional `$schema` +// is silently ignored by the runtime but kept here for +// forward-contract use; it must match the proposal URL if present. +const HOOK_DOCUMENT_FIELDS = new Set([ + '$schema', + 'hooks', + ...KNOWN_HOOK_EVENTS, ]); +// The closed-schema allowlist for an outer (matcher) entry. The +// 0.3.10 parser walks each matcher entry and requires a +// non-empty `hooks[]` array; `matcher` is optional. +const HOOK_MATCHER_FIELDS = new Set(['matcher', 'hooks']); +// The closed-schema allowlist for an inner (command) descriptor. +// The 0.3.10 parser only consumes `type` / `command` / `timeout` +// from the inner entry; everything else is dropped on the floor. +// We reject the previous companion's fields here so a Plugin +// migrating from 0.2.4 to 0.3.10 gets a clear error rather than +// a silent no-op. +const HOOK_COMMAND_FIELDS = new Set(['type', 'command', 'timeout']); +// Field names the 0.3.10 parser does not consume (either +// runtime-internal discriminators or 0.2.4-only fields). The +// validator surfaces these as a closed-schema violation when they +// appear in a hook entry, with a message that points at the +// spec field-vocabulary table. `type` is intentionally NOT in +// this set: it is a regular field on the inner command +// descriptor (see HOOK_COMMAND_FIELDS) whose value is checked +// separately. const HOOK_RESERVED_FIELDS = new Set([ - 'type', 'shell', 'prompt', 'http', 'agent', 'script', 'function', + 'args', + 'env', + 'cwd', + 'pattern', + 'regex', + 'glob', + 'once', + 'timeoutMs', ]); -const HOOK_TIMEOUT_DEFAULT = 30000; -const HOOK_TIMEOUT_MIN = 100; -const HOOK_TIMEOUT_MAX = 600000; +// `timeout` is in seconds in the 0.3.10 schema (the parser +// multiplies by 1000 internally). 1s..600s covers the +// "tool-call lifetime" floor through the "compaction pass" ceiling +// with margin. +const HOOK_TIMEOUT_DEFAULT = 30; +const HOOK_TIMEOUT_MIN = 1; +const HOOK_TIMEOUT_MAX = 600; function rejectUnknownFields(record, allowed, label) { for (const key of Object.keys(record)) { @@ -211,78 +253,66 @@ function rejectUnknownFields(record, allowed, label) { } } +// Validate an inner (command) descriptor. The 0.3.10 parser +// requires `command` when `type === "command"` (the only type +// the parser dispatches today); `matcher` lives on the outer +// entry, not here. +export function validateHookCommand(value, label) { + assert(isRecord(value), `${label}: hook command must be an object`); + rejectUnknownFields(value, HOOK_COMMAND_FIELDS, label); + const type = value.type === undefined ? 'command' : value.type; + assert(type === 'command', `${label}: type must be "command" in @minimax-ai/code@0.3.10 (the only dispatched handler kind); got ${JSON.stringify(type)}`); + assert(typeof value.command === 'string' && value.command.length > 0, `${label}: command is required and must be a non-empty string`); + if (value.timeout !== undefined) { + assert(Number.isInteger(value.timeout) && value.timeout >= HOOK_TIMEOUT_MIN && value.timeout <= HOOK_TIMEOUT_MAX, `${label}: timeout must be an integer between ${HOOK_TIMEOUT_MIN} and ${HOOK_TIMEOUT_MAX} seconds (the 0.3.10 parser multiplies by 1000)`); + } + return { ...value, type, command: value.command, timeout: value.timeout === undefined ? HOOK_TIMEOUT_DEFAULT : value.timeout }; +} + +// Validate an outer (matcher) entry. The 0.3.10 parser requires +// `hooks[]`; `matcher` is optional. This is the structural shape +// that 0.2.4 flat entries did not satisfy. export function validateHookEntry(value, label) { assert(isRecord(value), `${label}: hook entry must be an object`); - rejectUnknownFields(value, HOOK_ENTRY_FIELDS, label); - assert(typeof value.command === 'string' && value.command.length > 0, `${label}: command is required`); - assert( - isBareCommand(value.command) || isContainedRelativePath(value.command), - `${label}: command must be a bare executable or a contained ./ path`, - ); - if (value.args !== undefined) { - assert(Array.isArray(value.args) && value.args.every((item) => typeof item === 'string' && item.length > 0), `${label}: args must be an array of non-empty strings`); - } - if (value.env !== undefined) { - assert(isRecord(value.env), `${label}: env must be an object`); - for (const [envKey, envValue] of Object.entries(value.env)) { - assert(!['PLUGIN_ROOT', 'PLUGIN_DATA'].includes(envKey), `${label}: env.${envKey} is reserved`); - assert(typeof envValue === 'string', `${label}: env.${envKey} must be a string`); - } - } - if (value.cwd !== undefined) { - assert( - typeof value.cwd === 'string' - && (isContainedRelativePath(value.cwd) || isContainedPluginPath(value.cwd)), - `${label}: cwd must be a contained ./ path (no '..', no '\\') or a path under \${PLUGIN_ROOT} or \${PLUGIN_DATA} (no '..', no '\\', no leading '/')`, - ); - } + rejectUnknownFields(value, HOOK_MATCHER_FIELDS, label); if (value.matcher !== undefined) { assert(typeof value.matcher === 'string' && value.matcher.length > 0, `${label}: matcher must be a non-empty string`); } - if (value.pattern !== undefined) { - assert(typeof value.pattern === 'string' && value.pattern.length > 0, `${label}: pattern must be a non-empty string`); - } - if (value.matcher !== undefined && value.pattern !== undefined) { - assert(value.matcher === value.pattern, `${label}: matcher and pattern must agree when both are set`); - } - if (value.regex !== undefined) { - assert(typeof value.regex === 'boolean', `${label}: regex must be a boolean`); - } - if (value.glob !== undefined) { - assert(typeof value.glob === 'boolean', `${label}: glob must be a boolean`); - } - if (value.timeout !== undefined) { - assert(Number.isInteger(value.timeout) && value.timeout >= HOOK_TIMEOUT_MIN && value.timeout <= HOOK_TIMEOUT_MAX, `${label}: timeout must be an integer between ${HOOK_TIMEOUT_MIN} and ${HOOK_TIMEOUT_MAX} ms`); - } - if (value.timeoutMs !== undefined) { - assert(Number.isInteger(value.timeoutMs) && value.timeoutMs >= HOOK_TIMEOUT_MIN && value.timeoutMs <= HOOK_TIMEOUT_MAX, `${label}: timeoutMs must be an integer between ${HOOK_TIMEOUT_MIN} and ${HOOK_TIMEOUT_MAX} ms`); - } - if (value.once !== undefined) { - assert(typeof value.once === 'boolean', `${label}: once must be a boolean`); + assert(Array.isArray(value.hooks) && value.hooks.length > 0, `${label}: hooks must be a non-empty array of command descriptors`); + for (let i = 0; i < value.hooks.length; i += 1) { + validateHookCommand(value.hooks[i], `${label}: hooks[${i}]`); } return value; } +// Validate the full hooks document. The 0.3.10 parser walks +// `Object.entries` over the body and treats each value as an event +// entry. The body is either `value.hooks` (the wrapper) or +// `value` itself (events sit on the root). Both shapes are +// accepted and produce identical behavior. export function validateHooksDocument(value, label) { assert(isRecord(value), `${label}: root must be an object`); rejectUnknownFields(value, HOOK_DOCUMENT_FIELDS, label); - // Round-4 fix: the previous check was `length > 0`, which accepted - // any non-empty string. The proposal pins a specific URL, so the - // validator must require that URL exactly. A plugin that wants to - // claim a different schema is welcome to publish a different - // proposal, but the validator cannot pretend a draft matches - // 0.1.0 just because the field is non-empty. - assert(value.$schema === HOOK_SCHEMA, `${label}: $schema must equal ${HOOK_SCHEMA}`); - assert(isRecord(value.hooks), `${label}: hooks must be an object`); + // The 0.3.10 runtime silently ignores `$schema`; the validator + // accepts it for forward contract but does not require it. When + // present, it must match the proposal URL exactly so a Plugin + // cannot claim a draft matches `0.1.0` just because the field + // is non-empty. + if (value.$schema !== undefined) { + assert(value.$schema === HOOK_SCHEMA, `${label}: $schema must equal ${HOOK_SCHEMA} when set`); + } + const body = 'hooks' in value ? value.hooks : value; + assert(isRecord(body), `${label}: hooks body must be an object (either the 'hooks' wrapper or the document root)`); const events = []; - for (const [eventName, entries] of Object.entries(value.hooks)) { + for (const [eventName, entries] of Object.entries(body)) { assert(KNOWN_HOOK_EVENTS.has(eventName), `${label}: ${eventName} is not a recognized event; expected one of ${[...KNOWN_HOOK_EVENTS].sort().join(', ')}`); - assert(Array.isArray(entries) && entries.length > 0, `${label}: ${eventName} must be a non-empty array`); + assert(Array.isArray(entries) && entries.length > 0, `${label}: ${eventName} must be a non-empty array of matcher entries`); for (let i = 0; i < entries.length; i += 1) { validateHookEntry(entries[i], `${label}: ${eventName}[${i}]`); } events.push(eventName); } + assert(events.length > 0, `${label}: at least one event entry is required`); return events.sort(); } diff --git a/test/validation.test.mjs b/test/validation.test.mjs index a8c20627..322a1bc0 100644 --- a/test/validation.test.mjs +++ b/test/validation.test.mjs @@ -4,6 +4,16 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; +// This suite targets the @minimax-ai/code hook contract as first +// shipped in @minimax-ai/code@0.3.10 (npm, 2026-09-08) and +// inherited unchanged by @minimax-ai/code@0.3.11 (the only +// 0.3.10 -> 0.3.11 change is a 401-token retry fix; the hook +// schema, the Ava dispatch wrapper, the Fwe allowlist, and the +// Uwe parser are byte-identical). Re-verified on a 0.3.11 +// install at 2026-09-10. The "0.3.10" references throughout +// this file are deliberate -- they record the first release +// that introduced the contract under test. + import { validateHooksDocument, validateHookEntry, @@ -53,32 +63,71 @@ test('validates supported MCP transports and reserved environment variables', () ); }); -test('accepts a Hook entry with allowed field vocabulary and rejects reserved discriminators', () => { - const entry = validateHookEntry({ - command: 'node', - args: ['${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs'], - env: { LOG: 'info' }, - cwd: '${PLUGIN_DATA}', +test('accepts a Hook entry (outer matcher) and a Hook command (inner descriptor)', () => { + // 0.3.10 schema: outer (matcher) entry holds {matcher, hooks[]}; + // inner (command) descriptor holds {type, command, timeout}. + // The previous companion's flat shape is rejected. + const outer = validateHookEntry({ matcher: 'Bash', - timeout: 5000, - once: false, + hooks: [{ + type: 'command', + command: 'node ${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs --event PreToolUse --state ${PLUGIN_DATA}/state.json', + timeout: 5, + }], }, 'hook'); - assert.equal(entry.command, 'node'); - assert.throws(() => validateHookEntry({ command: 'node', type: 'shell' }, 'hook'), /reserved internal discriminator/u); - assert.throws(() => validateHookEntry({ command: 'node', env: { PLUGIN_ROOT: 'bad' } }, 'hook'), /reserved/u); - assert.throws(() => validateHookEntry({ command: 'node', cwd: '/etc' }, 'hook'), /cwd must be/u); - assert.throws(() => validateHookEntry({ command: 'node', timeout: 1 }, 'hook'), /timeout must be an integer/u); + assert.equal(outer.hooks[0].command.startsWith('node '), true); + // Reserved runtime-internal discriminators are still rejected + // as values of `type`. The validator gives the more specific + // "type must be 'command'" error rather than the closed-schema + // reserved-field error, because the user provided a valid + // field name with an invalid value. This is intentional: the + // more specific message is more actionable. + assert.throws(() => validateHookEntry({ matcher: '*', hooks: [{ type: 'shell' }] }, 'hook'), /type must be "command"/u); + // 0.2.4 fields are now closed-schema violations (validator + // surfaces them as reserved so a Plugin migrating from 0.2.4 + // gets a clear error rather than a silent no-op). + assert.throws(() => validateHookEntry({ matcher: '*', hooks: [{ args: ['x'] }] }, 'hook'), /reserved/u); + assert.throws(() => validateHookEntry({ matcher: '*', hooks: [{ env: { LOG: 'info' } }] }, 'hook'), /reserved/u); + assert.throws(() => validateHookEntry({ matcher: '*', hooks: [{ cwd: './scripts' }] }, 'hook'), /reserved/u); + assert.throws(() => validateHookEntry({ matcher: '*', hooks: [{ once: true }] }, 'hook'), /reserved/u); + // `type` other than "command" is rejected because 0.3.10 only + // dispatches command handlers. + assert.throws(() => validateHookEntry({ matcher: '*', hooks: [{ type: 'prompt', command: 'x' }] }, 'hook'), /type must be "command"/u); + // `command` is required for the command kind. + assert.throws(() => validateHookEntry({ matcher: '*', hooks: [{}] }, 'hook'), /command is required/u); + // `timeout` is in seconds and the 0.2.4 millisecond range is + // out of bounds. The validator must reject `5000` here + // because 5000 seconds is > 600. + assert.throws(() => validateHookEntry({ matcher: '*', hooks: [{ command: 'x', timeout: 5000 }] }, 'hook'), /timeout must be an integer/u); + assert.throws(() => validateHookEntry({ matcher: '*', hooks: [{ command: 'x', timeout: 0 }] }, 'hook'), /timeout must be an integer/u); + // matcher must be a non-empty string when set. + assert.throws(() => validateHookEntry({ matcher: 123, hooks: [{ command: 'x' }] }, 'hook'), /matcher must be a non-empty string/u); + // The outer entry requires a non-empty hooks[] array (this is + // the 0.3.10 parser's most-referenced warning: the previous + // companion's flat shape satisfies neither matcher nor + // hooks[], so a Plugin that wants to fire must use the nested + // shape). + assert.throws(() => validateHookEntry({ matcher: '*' }, 'hook'), /hooks must be a non-empty array/u); + assert.throws(() => validateHookEntry({ matcher: '*', hooks: [] }, 'hook'), /hooks must be a non-empty array/u); }); -test('accepts a Hooks document that targets the experimental io.minimax.mcode namespace', () => { +test('accepts a Hooks document that targets the experimental io.minimax.mcode namespace (0.3.10 nested shape)', () => { const events = validateHooksDocument({ $schema: 'https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json', hooks: { - PreToolUse: [{ command: 'node', args: ['${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs'] }], - SessionEnd: [{ command: 'node' }], + PreToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: 'node ${PLUGIN_ROOT}/io.minimax.mcode/hooks/scripts/record.mjs' }] }], + SessionEnd: [{ hooks: [{ command: 'node' }] }], }, }, 'hooks.json'); assert.deepEqual(events, ['PreToolUse', 'SessionEnd']); + // The 0.3.10 parser walks the document body and treats each + // value as an event entry. The body is either `value.hooks` + // (the wrapper) or `value` itself (events sit on the root). + // Both shapes are accepted and produce identical behavior. + const directEvents = validateHooksDocument({ + PreToolUse: [{ hooks: [{ command: 'node' }] }], + }, 'hooks.json'); + assert.deepEqual(directEvents, ['PreToolUse']); // Round-4 fix: $schema is now pinned, so the URL must match exactly. // The old "any non-empty string" check is gone, so the error message // also changes — we now expect "must equal" rather than letting the @@ -87,18 +136,40 @@ test('accepts a Hooks document that targets the experimental io.minimax.mcode na () => validateHooksDocument({ $schema: 'x', hooks: { UnknownEvent: [{ command: 'node' }] } }, 'hooks.json'), /\$schema must equal/u, ); + // The 0.3.10 catalog includes 15 events: 12 portable + 3 + // streaming. Streaming events are accepted by the validator + // (Fwe dispatch is a runtime question). + const streaming = validateHooksDocument({ + hooks: { + MessageComplete: [{ hooks: [{ command: 'node' }] }], + StreamChunk: [{ hooks: [{ command: 'node' }] }], + StreamChunkThreshold: [{ hooks: [{ command: 'node' }] }], + }, + }, 'hooks.json'); + assert.deepEqual(streaming, ['MessageComplete', 'StreamChunk', 'StreamChunkThreshold']); assert.throws( () => validateHooksDocument({ $schema: 'https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json', hooks: { UnknownEvent: [{ command: 'node' }] } }, 'hooks.json'), /not a recognized event/u, ); + // The 0.2.4 flat shape is rejected because the inner descriptor + // is not an outer matcher entry; `command` and `args` are not + // in the matcher-entry allowlist. assert.throws( - () => validateHooksDocument({ $schema: 'https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json', hooks: { PreToolUse: [] } }, 'hooks.json'), - /non-empty array/u, + () => validateHooksDocument({ + $schema: 'https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json', + hooks: { PreToolUse: [{ command: 'node', args: ['x'] }] }, + }, 'hooks.json'), + /not a recognized Hook field/u, ); assert.throws( - () => validateHooksDocument({ hooks: { PreToolUse: [{ command: 'node' }] } }, 'hooks.json'), - /\$schema must equal/u, + () => validateHooksDocument({ $schema: 'https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json', hooks: { PreToolUse: [] } }, 'hooks.json'), + /non-empty array/u, ); + // Without $schema, the document is still accepted — the + // 0.3.10 runtime silently ignores the field. + assert.doesNotThrow(() => validateHooksDocument({ + hooks: { PreToolUse: [{ hooks: [{ command: 'node' }] }] }, + }, 'hooks.json')); }); test('validatePluginDirectory picks up an io.minimax.mode hooks extension without requiring it', async () => { @@ -123,9 +194,11 @@ test('validatePluginDirectory picks up an io.minimax.mode hooks extension withou ].join('\n'), 'utf8'); const hooksDir = path.join(root, 'io.minimax.mcode', 'hooks'); await mkdir(hooksDir, { recursive: true }); + // 0.3.10 nested shape: outer matcher entry wraps an inner + // hooks[] array of command descriptors. await writeFile(path.join(hooksDir, 'hooks.json'), JSON.stringify({ $schema: 'https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json', - hooks: { SessionStart: [{ command: 'node' }] }, + hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'node' }] }] }, })); const result = await validatePluginDirectory(root); assert.deepEqual(result.clientExtensions, [{ namespace: 'io.minimax.mcode', events: ['SessionStart'] }]); @@ -185,7 +258,7 @@ test('validatePluginDirectory rejects hooks.json with an unrecognized event', as await mkdir(hooksDir, { recursive: true }); await writeFile(path.join(hooksDir, 'hooks.json'), JSON.stringify({ $schema: 'https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json', - hooks: { Bogus: [{ command: 'node' }] }, + hooks: { Bogus: [{ hooks: [{ command: 'node' }] }] }, })); await assert.rejects(validatePluginDirectory(root), /not a recognized event/u); } finally { @@ -194,45 +267,53 @@ test('validatePluginDirectory rejects hooks.json with an unrecognized event', as }); test('validateHookEntry rejects unknown fields (closed schema)', () => { + // 0.3.10 schema: outer (matcher) entry only allows {matcher, hooks[]}; + // inner (command) descriptor only allows {type, command, timeout}. assert.throws( - () => validateHookEntry({ command: 'node', evil: 'x' }, 'hook'), + () => validateHookEntry({ matcher: '*', hooks: [{ command: 'node', evil: 'x' }] }, 'hook'), /not a recognized Hook field/u, ); assert.throws( - () => validateHookEntry({ command: 'node', sideChannel: true }, 'hook'), + () => validateHookEntry({ matcher: '*', hooks: [{ command: 'node', sideChannel: true }] }, 'hook'), + /not a recognized Hook field/u, + ); + // The previous companion's `command` at the outer level is no + // longer valid; the 0.3.10 parser reads it as a missing hooks[]. + assert.throws( + () => validateHookEntry({ command: 'node' }, 'hook'), /not a recognized Hook field/u, ); }); -// Round-4 fix: the previous regex accepted './../outside' and -// '${PLUGIN_ROOT}/../../outside' because it only checked the -// prefix. These tests pin the negative contract. -test('validateHookEntry rejects cwd traversal in ./ paths (R4-1)', () => { - assert.throws(() => validateHookEntry({ command: 'node', cwd: './../outside' }, 'hook'), - /cwd must be/u); - assert.throws(() => validateHookEntry({ command: 'node', cwd: './foo/../../bar' }, 'hook'), - /cwd must be/u); - assert.throws(() => validateHookEntry({ command: 'node', cwd: './foo\\bar' }, 'hook'), - /cwd must be/u); - assert.throws(() => validateHookEntry({ command: 'node', cwd: '..' }, 'hook'), - /cwd must be/u); - // Sanity: a properly contained ./ path is still accepted. - assert.doesNotThrow(() => validateHookEntry({ command: 'node', cwd: './scripts' }, 'hook')); -}); - -test('validateHookEntry rejects cwd traversal in ${PLUGIN_ROOT} / ${PLUGIN_DATA} paths (R4-1)', () => { - assert.throws(() => validateHookEntry({ command: 'node', cwd: '${PLUGIN_ROOT}/../outside' }, 'hook'), - /cwd must be/u); - assert.throws(() => validateHookEntry({ command: 'node', cwd: '${PLUGIN_DATA}/foo/../bar/..' }, 'hook'), - /cwd must be/u); - assert.throws(() => validateHookEntry({ command: 'node', cwd: '${PLUGIN_ROOT}/foo/..' }, 'hook'), - /cwd must be/u); - // Sanity: contained paths still accepted. - assert.doesNotThrow(() => validateHookEntry({ command: 'node', cwd: '${PLUGIN_ROOT}/io.minimax.mcode/hooks' }, 'hook')); - assert.doesNotThrow(() => validateHookEntry({ command: 'node', cwd: '${PLUGIN_DATA}' }, 'hook')); +// `cwd` was removed from the 0.3.10 schema because the parser +// passes the single command string to the platform shell, which +// already handles per-command working directory. The previous +// companion's R4-1 cwd-traversal tests are replaced by a single +// test that the field is now closed-schema-rejected (it appears +// in HOOK_RESERVED_FIELDS, so the validator surfaces it as a +// reserved internal discriminator). +test('validateHookEntry rejects the 0.2.4 cwd field on inner command descriptors', () => { + assert.throws( + () => validateHookEntry({ matcher: '*', hooks: [{ command: 'node', cwd: './scripts' }] }, 'hook'), + /reserved internal discriminator/u, + ); + assert.throws( + () => validateHookEntry({ matcher: '*', hooks: [{ command: 'node', cwd: '${PLUGIN_DATA}' }] }, 'hook'), + /reserved internal discriminator/u, + ); + // `${PLUGIN_ROOT}/../etc` was the round-4 false positive on + // 0.2.4; the 0.3.10 schema rejects the field outright, so the + // path-traversal pattern is now unrepresentable. + assert.throws( + () => validateHookEntry({ matcher: '*', hooks: [{ command: 'node', cwd: '${PLUGIN_ROOT}/../etc' }] }, 'hook'), + /reserved internal discriminator/u, + ); }); test('validateMcp rejects the same cwd traversal patterns (R4-1)', () => { + // MCP `cwd` semantics are unchanged; this is the same negative + // contract as the previous companion. The 0.3.10 schema only + // affects the hooks namespace. const base = { $schema: 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json' }; assert.throws( () => validateMcp({ ...base, mcpServers: { bad: { type: 'stdio', command: 'node', cwd: './../escape' } } }), @@ -249,7 +330,7 @@ test('validateHooksDocument pins the $schema URL to the proposal (R4-3)', () => assert.throws( () => validateHooksDocument({ $schema: 'https://example.com/wrong/schema.json', - hooks: { SessionStart: [{ command: 'node' }] }, + hooks: { SessionStart: [{ hooks: [{ command: 'node' }] }] }, }, 'hooks.json'), /\$schema must equal/u, ); @@ -259,32 +340,107 @@ test('validateHooksDocument pins the $schema URL to the proposal (R4-3)', () => assert.throws( () => validateHooksDocument({ $schema: '', - hooks: { SessionStart: [{ command: 'node' }] }, + hooks: { SessionStart: [{ hooks: [{ command: 'node' }] }] }, }, 'hooks.json'), /\$schema must equal/u, ); + // When the document omits $schema, the 0.3.10 runtime + // silently accepts it. The validator mirrors that. + assert.doesNotThrow(() => validateHooksDocument({ + hooks: { SessionStart: [{ hooks: [{ command: 'node' }] }] }, + }, 'hooks.json')); }); -test('validateHookEntry type-checks matcher, pattern, regex, glob, once, timeout', () => { - assert.throws(() => validateHookEntry({ command: 'node', matcher: 123 }, 'hook'), /matcher must be a non-empty string/u); - assert.throws(() => validateHookEntry({ command: 'node', pattern: '' }, 'hook'), /pattern must be a non-empty string/u); - assert.throws(() => validateHookEntry({ command: 'node', regex: 'yes' }, 'hook'), /regex must be a boolean/u); - assert.throws(() => validateHookEntry({ command: 'node', glob: 1 }, 'hook'), /glob must be a boolean/u); - assert.throws(() => validateHookEntry({ command: 'node', once: 'yes' }, 'hook'), /once must be a boolean/u); - assert.throws(() => validateHookEntry({ command: 'node', timeout: '30s' }, 'hook'), /timeout must be an integer/u); - assert.throws(() => validateHookEntry({ command: 'node', timeoutMs: 1 }, 'hook'), /timeoutMs must be an integer/u); - assert.throws(() => validateHookEntry({ command: 'node', timeoutMs: 0 }, 'hook'), /timeoutMs must be an integer/u); +test('validateHookEntry and validateHookCommand type-check the 0.3.10 field vocabulary', () => { + // matcher on the outer entry must be a non-empty string when set. + assert.throws( + () => validateHookEntry({ matcher: 123, hooks: [{ command: 'node' }] }, 'hook'), + /matcher must be a non-empty string/u, + ); + assert.throws( + () => validateHookEntry({ matcher: '', hooks: [{ command: 'node' }] }, 'hook'), + /matcher must be a non-empty string/u, + ); + // command on the inner descriptor must be a non-empty string. + assert.throws( + () => validateHookEntry({ matcher: '*', hooks: [{ command: '' }] }, 'hook'), + /command is required/u, + ); + assert.throws( + () => validateHookEntry({ matcher: '*', hooks: [{ command: 123 }] }, 'hook'), + /command is required/u, + ); + // timeout must be an integer in the 1..600 seconds range; the + // 0.2.4 millisecond range (timeoutMs, very large numbers) is + // no longer valid. + assert.throws( + () => validateHookEntry({ matcher: '*', hooks: [{ command: 'x', timeout: '30s' }] }, 'hook'), + /timeout must be an integer/u, + ); + assert.throws( + () => validateHookEntry({ matcher: '*', hooks: [{ command: 'x', timeout: 601 }] }, 'hook'), + /timeout must be an integer/u, + ); + assert.throws( + () => validateHookEntry({ matcher: '*', hooks: [{ command: 'x', timeout: 0 }] }, 'hook'), + /timeout must be an integer/u, + ); + // The 0.2.4 fields pattern / regex / glob / once / timeoutMs + // are closed-schema rejected. The validator surfaces them as + // "reserved" because they appeared in the 0.2.4 companion and + // silently no-op in 0.3.10; we want a loud failure instead. + for (const field of ['pattern', 'regex', 'glob', 'once', 'timeoutMs']) { + assert.throws( + () => validateHookEntry({ matcher: '*', hooks: [{ command: 'node', [field]: 'x' }] }, 'hook'), + /reserved internal discriminator/u, + `${field} must be a reserved field`, + ); + } + // type must be the string "command" (the only kind 0.3.10 + // dispatches); any other value is rejected. The reserved + // discriminators (`prompt`, `http`, `agent`, `shell`, + // `function`, `script`) are caught by the same check — the + // validator gives the more specific "type must be 'command'" + // error rather than the closed-schema reserved-field error, + // because the user provided a valid field name with an + // invalid value. This is intentional: the more specific + // message is more actionable. + for (const badType of ['code', 'javascript', 'CODE', 'foo', '', 'prompt', 'http', 'agent', 'shell', 'function', 'script']) { + assert.throws( + () => validateHookEntry({ matcher: '*', hooks: [{ command: 'node', type: badType }] }, 'hook'), + /type must be "command"/u, + `type=${JSON.stringify(badType)} must be rejected`, + ); + } + // type is allowed when omitted (defaults to "command") and + // when explicitly "command". + assert.doesNotThrow(() => validateHookEntry({ matcher: '*', hooks: [{ command: 'node' }] }, 'hook')); + assert.doesNotThrow(() => validateHookEntry({ matcher: '*', hooks: [{ type: 'command', command: 'node' }] }, 'hook')); }); test('validateHooksDocument rejects unknown root fields (closed schema)', () => { + // 0.3.10: the document body is either `value.hooks` (the + // wrapper) or the root itself; either way, every top-level + // field must be in the closed-schema allowlist. `extra` is + // neither a known event name nor `$schema` nor `hooks`. assert.throws( () => validateHooksDocument({ $schema: 'https://minimax.io/schemas/mcode-hooks/0.1.0/hooks.schema.json', - hooks: { SessionStart: [{ command: 'node' }] }, + hooks: { SessionStart: [{ hooks: [{ command: 'node' }] }] }, extra: true, }, 'hooks.json'), /extra is not a recognized Hook field/u, ); + // An event name on the root is also closed-schema valid, but a + // typo is rejected at the root closed-schema level (the + // event-name check is reached only after the root has been + // confirmed to be a known event name or the wrapper). + assert.throws( + () => validateHooksDocument({ + PreToolUs: [{ hooks: [{ command: 'node' }] }], + }, 'hooks.json'), + /PreToolUs is not a recognized Hook field/u, + ); }); test('record.mjs writes state under PLUGIN_DATA even when it is outside PLUGIN_ROOT', async () => {