Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
7.8 KiB
Loop Hook Dispatch Contract
Generic reference for consuming the --raw JSON output of msd_run loop render-hooks <point>
in any host-loop workflow. This document is point-agnostic — it applies to every loop
extension point (discuss:pre, discuss:post, plan:pre, plan:post, execute:pre, execute:wave:pre,
execute:wave:post, execute:post, verify:pre, verify:post, ship:pre, ship:post).
Envelope shape
{
"point": "discuss:pre",
"activeHooks": [
{ "kind": "contribution", "into": "orchestrator", "fragment": { "inline": "..." } },
{ "kind": "step", "ref": { "skill": "my-skill" } },
{ "kind": "gate", "check": { "query": "..." }, "blocking": true, "onError": "skip" }
],
"rendered": "..."
}
activeHooks is an array of enabled hook entries for the named point. It is empty (or absent)
when no capability has registered an active hook at this point — treat that as a no-op.
Dispatch rules by kind
contribution
Inject fragment.inline verbatim into the context for the role named in into
(e.g. orchestrator, planner). Do not paraphrase — the text is the product.
step
Dispatch the referenced unit. Exactly one of ref.skill, ref.agent, or ref.command is set.
-
ref.skillpresent → dispatch via the Skill tool with skill idmsd-<ref.skill>. -
ref.agentpresent → dispatch via the Agent tool withsubagent_type=ref.agent. Before dispatching an agent, print the canonical liveness banner so users know silence is expected and do not kill a healthy agent:◆ Spawning <agent>... (runs in a subagent — no output until it returns; expected, not a freeze) -
ref.commandpresent → validate it IN-CONTEXT first, before any shell use. It comes from a capability manifest, which may be third-party. Check the value you read fromactiveHooksagainst^[a-z][a-z0-9-]*( [a-z][a-z0-9-]*)*$yourself — never by pasting it into a shell command to be tested there, because a value carrying a quote,;,`,$(, or a newline would terminate the assignment and run as its own statement before any shell-side check could execute. A value that fails is a malformed manifest: record a warning, skip that hook, continue to the next entry. Only a value that has passed is run, with the phase number appended:msd_run ${ref.command} --phase "${PHASE_NUMBER}" --raw
Wait for the result before continuing to the next hook or the next step.
supportsReviewerLanes (optional, boolean). A step entry may carry
supportsReviewerLanes: true alongside ref (#4209). A workflow opts a step into external
reviewer-lane dispatch by calling msd_run review-lane dispatch-step --cap-id <capId> --point <point> --explicit <slugs> ... — dispatch-step resolves its OWN active hook for <point> (via
resolveActiveHooksForPoint, the same in-process resolver loop render-hooks itself calls) and
checks whether <capId>'s hook carries this field before proceeding; the workflow does not
resolve or gate on the trait itself, only passes the two flags naming which step it is. When the
trait reads exactly true, dispatch-step routes through dispatchReviewerLanes, the one
interpreter in src/reviewer-step-dispatch.cts that reuses the existing reviewer-lane selection,
planning, and invocation machinery, so any explicitly selected reviewer lane also reviews the
same scope. Absent or false is inert: dispatch-step itself is a no-op (a non-boolean value is
rejected by capability-validator.cjs at load time, so it never reaches dispatch-step at all).
This is the only place a step opts into reviewer-lane support: a capability beyond code-review
reuses it by declaring the same trait on its own step and calling dispatch-step with
--cap-id/--point, with zero bespoke TRAIT-RESOLUTION code of its own. The workflow still owns
matching its own CLI flags against the reviewer-lane roster and assembling the evidence block
handed to its consolidator — those are NOT part of what this trait makes reusable.
A step is advisory by construction: it never blocks or redirects the host workflow —
that is what a gate is for. Each dispatch is best-effort; on error record a warning and
continue, honoring onError.
A point whose workflow hand-rolls one kind does not implement this contract. Several
host workflows historically matched a single hook (e.g. execute:post matched only
ref.skill == "code-review"), so any other step registered there was declared and silently
never run. When a workflow defers to this file, it dispatches every active step entry,
not one shape of one.
gate
Validate check before any shell use. check.query and check.predicate come from a
capability manifest, which may be third-party — and gates[].check is not one of the
executable surfaces the install consent prompt discloses (hooks, command modules,
mcpServers, reviewer lanes), so a capability can be consented to as declarative-only and
still reach a shell through a gate. Check the query value you read from activeHooks against
^[a-z][a-z0-9-]*( [a-z][a-z0-9-]*)*$ yourself, IN-CONTEXT — never by pasting it into a
shell command to be tested there, because a value carrying a quote, ;, a `, $(, or a
newline would terminate the assignment and run as its own statement before any shell-side
check could execute. A value that fails is a malformed manifest: record a warning, route it
per the gate's onError, and do not run it. Pass check.predicate as a single argv
element for the same reason — never re-quote it into a shell string, where an apostrophe
would close the literal. This is the identical requirement step → ref.command carries
above; it was stated there and omitted here, which is the gap #3559 closed.
Evaluate check (one of query, predicate, or agentVerdict). Then honor blocking:
blocking: true→ if the check returnsblock: true, surfacecheck.messageto the user and stop the current step. Do not continue.blocking: false→ advisory only; surface the message but continue regardless of outcome.
Honor onError if the check itself errors: skip means treat as non-blocking and continue;
halt means surface the error and stop.
Empty / absent activeHooks
If activeHooks is absent, null, or an empty array, skip silently and continue to the next
step in the workflow. No output to the user is needed.
The execute:task point (a different shape)
execute:task exists below wave granularity — it is evaluated once per task, inside the
execute:wave:pre / execute:wave:post bracket, immediately before that task's read_first
gate. It is not one of the 12 points documented above, does not appear in steps /
contributions / gates, and is never dispatched through msd_run loop render-hooks <point> or
this file's activeHooks envelope.
Instead, a capability declares task-content resolution directly in its manifest body via
taskContentResolver (trackerPrefix + a bounded invoke) — see
Capability manifest reference. execute-plan.md's
per-task loop calls msd_run task resolve-content --plan <path> --task-id <tracker-id> --raw
directly, an unconditional, required subprocess invocation with a real, binding exit code —
never a prose-dispatched step/gate entry chosen from an activeHooks array.
This point always runs — there is no when config gate and no autonomous-mode elision. That is
deliberate, not an oversight: the twelve points above are best-effort prose dispatch, which
execute:task's hard-halt safety property cannot be built on top of (a missed dispatch is
indistinguishable from a legitimate resolver-empty fallback). See
ADR-3646 for the full rationale,
including why a kind: "gate" shape was rejected outright.