From b2a484de6be6b493939ab51cbceb97c7f4519ef7 Mon Sep 17 00:00:00 2001 From: antianqi <75944423+antianqi@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:10:13 +0800 Subject: [PATCH 1/3] fix(hooks-detailed-spec): align io.minimax.mcode schema with @minimax-ai/code@0.3.10 runtime Updates the PR #20 companion to record the hook shape the 0.3.10 runtime actually accepts. The previous companion's flat shape (`{command, args, matcher, timeout}` at the event level with no `hooks[]` wrapper) is silently skipped by the 0.3.10 parser with the warning "hooks.json matcher entry is missing a hooks[] array, skipping". Every Plugin written against the previous shape (including mcode-island v0.3.0) would receive zero deliveries in 0.3.10 even when the event name is in the Fwe allowlist. The new spec describes what the runtime actually parses: - outer (matcher) entry: {matcher, hooks[]} - inner (command) descriptor: {type, command, timeout} - `type` is the only consumed handler-kind discriminator and must be "command" (the only kind the parser dispatches in 0.3.10) - `command` is a single shell-executed string; `args[]` is not consumed by the parser and is rejected by the closed-schema validator so a Plugin migrating from 0.2.4 gets a clear error rather than a silent no-op - `timeout` is in seconds (the parser multiplies by 1000 internally); the previous companion's millisecond range (e.g. 5000) is out of bounds and would have meant 5,000,000 ms = 83 minutes at runtime - 15 PascalCase events (12 portable + 3 0.3.10 streaming): MessageComplete, StreamChunk, StreamChunkThreshold 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` path. The Plugin registry accepts the `io.minimax.mcode` namespace in plugin.json but the hook-config parser does not consult that field. The spec records this caveat so a Plugin that wants its hooks to fire knows it must also install hooks.json into one of the two Runtime-resolved locations. Validation - scripts/lib/validation.mjs: HOOK_DOCUMENT_FIELDS grows to include all 15 events plus the `hooks` wrapper. New HOOK_MATCHER_FIELDS (`matcher`, `hooks`) and HOOK_COMMAND_FIELDS (`type`, `command`, `timeout`) split the previous HOOK_ENTRY_FIELDS allowlist into outer and inner halves. validateHookEntry walks `hooks[]`; validateHookCommand checks the inner descriptor. HOOK_RESERVED_FIELDS now covers the 0.2.4 fields the parser does not consume (`args`, `env`, `cwd`, `pattern`, `regex`, `glob`, `once`, `timeoutMs`) plus the 0.2.4 internal discriminators (`shell`, `prompt`, `http`, `agent`, `script`, `function`). `type` is moved out of the reserved set and into HOOK_COMMAND_FIELDS so the validator gives a more specific "type must be 'command'" error for bad values. timeout range is 1..600 seconds. `cwd` traversal tests are removed (cwd is no longer a hook field). - The 0.2.4 cwd / `args` / `pattern` / `regex` / `glob` / `once` / `timeoutMs` fields are now closed-schema violations on inner descriptors, surfaced as "reserved internal discriminator" so a Plugin migrating from 0.2.4 to 0.3.10 gets a clear error rather than a silent no-op. Test evidence - test/validation.test.mjs: existing tests updated to the 0.3.10 nested shape. New tests cover: outer / inner schema split, type "command" required, 0.2.4 fields rejected as reserved, both `{"hooks": {...}}` and `{...}` document bodies, $schema optional, 15-event catalog including the three 0.3.10 streaming events. 21/21 pass. - negative-injection self-audit (per the round-4 audit rule): three contracts were broken and the validator caught each: 1. 0.2.4 flat shape -> REJECTED with "command is not a recognized Hook field; expected one of hooks, matcher" 2. `args` added to inner descriptor -> REJECTED with "args is a reserved internal discriminator and is not allowed in a portable Hook entry" 3. `timeout: 5000` (out of 1..600 seconds) -> REJECTED with "timeout must be an integer between 1 and 600 seconds (the 0.3.10 parser multiplies by 1000)" After each injection the example was restored and the validator passed (12 events, no false green). Design compliance - spec: companion-only document; the portable proposal in proposals/hooks.md (commit d86625d) is unchanged. Every normative rule in this PR is marked Portable / Mcode-specific / Companion-only observability so the eventual merge with the portable proposal has a clear scope boundary. - example: examples/hello-mcode-hooks targets every event in the 0.3.10 catalog with one record.mjs invocation each. Five of the twelve events (PreToolUse, PostToolUse, SessionStart, SessionEnd, UserPromptSubmit) are in the Fwe allowlist and would auto-dispatch; the remaining seven load but never fire and are recorded for forward compatibility. - validator: the closed-schema rejection messages name the field and the spec section that constrains it, so a Plugin author can map the error to a fix without re-reading the spec. Companion evidence - 14/14 manual invocations on Windows 11 24H2 + @minimax-ai/code@0.3.10 (mcode-island v0.4.0, 2026-09-09); 5/12 events auto-dispatched by the runtime, 7/12 recorded for forward compatibility. Recorded in the spec "End-to-end smoke" section. Known gaps (not addressed here, recorded in the spec) - the seven `forward` events (Stop, PreCompact, Notification, SubagentStart, SubagentStop, PermissionRequest, PermissionDenied) load cleanly but never fire in 0.3.10. Lifting them into the Fwe allowlist is a runtime change, not a spec change. - the 0.3.10 streaming events (MessageComplete, StreamChunk, StreamChunkThreshold) are not in the portable proposal. Adoption or rejection is an upstream decision. - the `decision` field, the `ask` value, and the `hookSpecificOutput` shape are backed by cli.js literal inspection only; no CI test exercises them. - Plugin-supplied `extensions.io.minimax.mcode.hooks` paths in plugin.json are accepted by the Plugin registry but the 0.3.10 hook-config parser does not read them. The spec documents the dataDir path; a future runtime release is expected to wire the Plugin path through. Refs - supersedes part of PR #20 (the schema description in the previous companion is replaced by the 0.3.10 schema here) - runtime: @minimax-ai/code@0.3.10 (npm, 2026-09-08) - portable proposal: proposals/hooks.md (commit d86625d) - user-reported regression recorded on 2026-09-09 --- examples/hello-mcode-hooks/README.md | 77 ++- .../io.minimax.mcode/hooks/hooks.json | 165 ++++-- proposals/hooks-detailed-spec.md | 483 +++++++++++------- scripts/lib/validation.mjs | 163 +++--- test/validation.test.mjs | 270 +++++++--- 5 files changed, 793 insertions(+), 365 deletions(-) diff --git a/examples/hello-mcode-hooks/README.md b/examples/hello-mcode-hooks/README.md index 9083fdac..453d9373 100644 --- a/examples/hello-mcode-hooks/README.md +++ b/examples/hello-mcode-hooks/README.md @@ -1,19 +1,28 @@ # 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. ## 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 +43,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..ef79d377 100644 --- a/proposals/hooks-detailed-spec.md +++ b/proposals/hooks-detailed-spec.md @@ -5,71 +5,80 @@ Status: Companion proposal to `proposals/hooks.md` (commit `d86625d`). Portable baseline: Agent Plugins 1.0. 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 +runtime-evidenced event catalog, decision semantics, document shape, and field vocabulary +actually shipped in `@minimax-ai/code@0.3.10` (npm, 2026-09-08). 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 most recent 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 +87,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 +132,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 +141,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 +172,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 +180,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 +388,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 +443,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,47 +452,65 @@ 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. - [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. diff --git a/scripts/lib/validation.mjs b/scripts/lib/validation.mjs index 0e2397a8..7c08629a 100644 --- a/scripts/lib/validation.mjs +++ b/scripts/lib/validation.mjs @@ -159,6 +159,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 +178,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 +246,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..eddfa620 100644 --- a/test/validation.test.mjs +++ b/test/validation.test.mjs @@ -53,32 +53,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 +126,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 +184,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 +248,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 +257,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 +320,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 +330,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 () => { From f562619f7708ed21564aa8213d1f4b25160bc68a Mon Sep 17 00:00:00 2001 From: antianqi Date: Thu, 10 Sep 2026 13:19:34 +0800 Subject: [PATCH 2/3] ci(validator): add windows-latest scope for PR #36 round-9 PR #36 round-9 review (hetaoBackend, 2026-09-10T01:40:52Z) on commit 1f5baf6 called out: '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 ci.yml (which deliberately dropped the over-broad validate-windows job that scanned every plugin's SKILL.md and hit a Windows-only YAML-frontmatter detection bug in scripts/validate.mjs), the new pattern is 'each PR adds its own scoped workflow'. This workflow is the scoped follow-up for the validator / example / proposal change in PR #36. Scope (intentionally narrow): - node --test test/validation.test.mjs on windows-latest. Exercises the validator contract on the real Windows image. 22 / 22 cases pass on this machine, 2026-09-10. Out of scope (and why): - 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 this PR neither owns nor touches -- the 'Test pass != contract obeyed' anti-pattern. Path filter triggers on: - proposals/** (the spec text) - scripts/lib/validation.mjs (the validator itself) - scripts/validate.mjs (in case the Windows bug gets fixed) - test/validation.test.mjs (the validator tests) - test-fixtures/** (negative-injection fixtures) - examples/** (the example plugin) - .github/workflows/validator-windows.yml (this file) The workflow_dispatch trigger lets a maintainer run the Windows-matrix check outside a PR (matches the pattern set by tool-map-windows.yml and mcode-island-windows.yml). Refs - PR #36 round-9 review on 1f5baf6d472f1e0045011530bba84ff67bfb5d46 - PR #37 round-9 fix to ci.yml (validator-windows job removed for the 'Test pass != contract obeyed' anti-pattern reason) --- .github/workflows/validator-windows.yml | 91 +++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 .github/workflows/validator-windows.yml 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. From 9ec471bd6c98989fe4ff37c8505dda1f8b62df9a Mon Sep 17 00:00:00 2001 From: antianqi Date: Thu, 10 Sep 2026 13:43:38 +0800 Subject: [PATCH 3/3] docs(validator): mark 0.3.10+ (verified on 0.3.11) @mcode 0.3.11 was released 2026-09-09. The only change vs 0.3.10 is a 401-token retry fix -- the hook schema, the Ava dispatch wrapper, the Fwe event allowlist, and the Uwe parser are byte-identical between 0.3.10 and 0.3.11. The contract this PR ships (the nested {matcher, hooks: [{type, command, timeout}]} shape, the 15-event catalog, the closed-schema allowlists, the timeout range 1..600 s) applies to both releases. Re-verified on a 0.3.11 install at 2026-09-10: - node --test test/validation.test.mjs -> 22 / 22 pass - node scripts/validate.mjs -> examples/hello-mcode-hooks/ passes; all 25+ plugins in the repository pass - The example hooks.json matches the schema accepted by both 0.3.10 and 0.3.11 This commit only updates text comments and adds a '0.3.10 -> 0.3.11 verification' section to the spec. No code change: the validator contract is the same. The '0.3.10' references throughout test/validation.test.mjs are intentional -- they record the first release that introduced the contract under test; the test suite runs unmodified against either release. Refs - npm view @minimax-ai/code@latest version (2026-09-10): 0.3.11 - @minimax-ai/code@0.3.11/chunks/chunk-P2ZQPHDU.js (Ava at offset 6553163, byte-identical to 0.3.10's chunk-CTHP2I62.js except for the file-name content hash) - @minimax-ai/code@0.3.10/chunks/chunk-CTHP2I62.js (Ava at offset 6553163) --- examples/hello-mcode-hooks/README.md | 5 +++- proposals/hooks-detailed-spec.md | 45 ++++++++++++++++++++++++---- scripts/lib/validation.mjs | 7 +++++ test/validation.test.mjs | 10 +++++++ 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/examples/hello-mcode-hooks/README.md b/examples/hello-mcode-hooks/README.md index 453d9373..db60a665 100644 --- a/examples/hello-mcode-hooks/README.md +++ b/examples/hello-mcode-hooks/README.md @@ -2,7 +2,10 @@ A minimal Plugin that ships one Skill and one experimental `io.minimax.mcode` Hook entry under the Agent Plugins 1.0 portable Hooks preview, conformant to the `@minimax-ai/code@0.3.10` -runtime hook schema. +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 diff --git a/proposals/hooks-detailed-spec.md b/proposals/hooks-detailed-spec.md index ef79d377..83c0e246 100644 --- a/proposals/hooks-detailed-spec.md +++ b/proposals/hooks-detailed-spec.md @@ -4,14 +4,23 @@ 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, document shape, and field vocabulary -actually shipped in `@minimax-ai/code@0.3.10` (npm, 2026-09-08). 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 most recent 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. +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 @@ -511,8 +520,32 @@ portable proposal's open decisions, with three additions: - `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 7c08629a..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; diff --git a/test/validation.test.mjs b/test/validation.test.mjs index eddfa620..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,