Files
msd-core/msd-core/references/loop-hook-dispatch.md
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
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.
2026-10-06 01:47:40 +02:00

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.skill present → dispatch via the Skill tool with skill id msd-<ref.skill>.

  • ref.agent present → dispatch via the Agent tool with subagent_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.command present → 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 from activeHooks against ^[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 returns block: true, surface check.message to 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.