Workflow & Automation
mstar-harness
btspoony/mstar-harness · ★ 67
Skill-driven harness/loop engineering workflow agent plugin.
安装
dsh plugin --profile web add github:btspoony/mstar-harness#path:/packages/dshGitHub 源码安装:首次需按提示配置 allowBuilds 构建授权后重试
For install and environment setup, plus plugin development tutorials, see DSH community docs ↗
README
Table of contents
- Usage
- Install paths
- Headless profile (one-shot runs)
- Configuration
- Composed row set
- What the plugin does when mounted
- Enforcement semantics
- Gates
- Status gate
- Dispatch gate
- Lease gate
- Skill lint gate
- Workflow / ralph gate
- LLM fallbacks integration
- Capability probe
- Role identity delivery (native subagent preset channel)
- Role seeds + adoption advisory
- Config surface
- Service
- Host adapter
English | 中文
Morning Star as a first-class dsh (DeepSeek Harness) host — a cordis function plugin that mounts the mstar engine in-process, implements the engine HostAdapter (host: 'dsh'), guards {HARNESS_DIR}/status.json writes (validate + advisory; repair-escape under hard), blocks disallowed subagent dispatches when Enforcement: hard is on, lints SKILL.md writes under the mounted skill roots, mounts the mstar skills/ mirror through the dsh skill-filesystem provider (single canonical mount), and appends a durable mstar-engine catalog row (persisted-log readers also accept the legacy mstar-engine-status identity) to every composed agent step. Boot with a dsh Loader app; everything acts through the seam's refusal/advisory channels, never by patching the tools.
This plugin's runtime requirement comes from the dsh host: the runtime that host provides must expose the native node:sqlite module the mstar store requires (the capability the store gate enforces). A Bun-hosted host is not version-gated - engines.bun in the workspace manifests declares the development toolchain - and the store's Node version floor applies to Node entrypoints only. Node is not additionally required for this package.
Usage
How a dsh app consumes the plugin — install paths, configuration, what mounts at boot, and the enforcement semantics.
Install paths
The package ships as a workspace package (workspaces: ["packages/*"]) with the engine bundled into dist/ at build time (bun run build; dist is gitignored). The install path is the profile bundle, added to the shipped web profile (dsh --profile web — the ready-made web app profile, dsh web), through the dsh.bundle.patch manifest — a patch layer mounted over the dsh-base defaults. The patch also mounts ONE host-neutral MCP server row — the shared mstar-mcp connection for the whole host; a deployment that needs a pinned harness root launches it with mstar mcp --harness <absolute path> (mount contract: bundle/README.md).
One-command CLI entry (recommended) — npx @mstar-harness/cli init --target dsh installs the full capability in one go: it runs the two dsh plugin --profile web add installs below in order (the mstar bundle first, then dsh-llm-fallbacks), and npx @mstar-harness/cli doctor --target dsh reports each plugin row as uninstalled / disabled / mounted / drifted (drifted = the fallbacks row is installed at a version other than the pinned one; init --target dsh re-adds the pin, doctor exits non-zero until then). It is the same two-command install, orchestrated; --no-fallbacks skips the second row (and with it the seeded roles — see What you get below).
(a) Registry install (published form) — the npm package carries the built dist/ (no build step on install):
dsh plugin --profile web add @mstar-harness/dsh
(b) Local checkout install (dev) — the package checkout itself, for iterating on the plugin:
cd <repo>/packages/dsh
dsh plugin --profile web add .
dsh plugin --profile <name> add <spec> initializes the profile on first use (web starts from the shipped template: @deepseek-ai/dsh-base + @deepseek-ai/dsh-web-app), forwards <spec> to pnpm in the profile directory, and reconciles the profile's dsh.profile.bundles layer list from the installed state: any dependency whose package.json declares dsh.bundle joins the layer stack. Relative specs (., file:/link:) anchor to the invoking directory, so add . runs from the package checkout; pnpm must be on PATH. A local checkout needs a prior bun run build (the prepare script is intentionally NOT used — the monorepo builds packages explicitly, matching cli/opencode).
(c) Optional capability: dsh-llm-fallbacks (second command) — the role-based subagent configuration capability (see LLM fallbacks integration) is a SEPARATE plugin row and must be installed with its own command:
dsh plugin --profile web add dsh-llm-fallbacks
The two-command install is the contract — folding a dsh-llm-fallbacks row into this bundle's patch is explicitly rejected (roadmap §8.3 F4): the loader has no insert-if-absent semantics, so a same-id insert is a duplicate loader entry id boot failure (the whole dsh session fails to start), and a different-id insert mounts the plugin twice — two apply() runs with split fallback state (per-context state stores, double listeners, config-override lottery) for anyone who also installs the package directly. Layer order is the reconcile append order: dsh-llm-fallbacks lands after dsh-base/llm-retry (its hard ordering requirement) and after the mstar row. Single-command multi-activation is an upstream feature gap (reconcile dedup or insert-if-absent patch semantics), not actionable from this repo.
What you get with zero configuration — with BOTH rows installed (via the CLI entry or the two commands above), the mstar plugin declares the 13 mode: subagent mstar role seeds (derived from the bundled harness-agents/ mirror, project-manager excluded) into the fallbacks taxonomy at boot: each seeded role's persona defaults to its mirror description plus the mandatory role-loading guidance line, the seeded state stays revertible (the fallbacks/revert-seed gateway / the settings rollback button), and the runtime advisory reports missing ids and persona overrides. The seeds mechanism is B4 — the installed-deployment e2e (tests/install-e2e.spec.ts) closes the verification loop: a real init --target dsh install into a temp DSH_HOME, booted from the installed artifacts, asserts all 13 ids present in the effective taxonomy with non-empty personas. Not included: model routing, automatch dispatch, or the dsh TUI.
Role identity for delegated children needs ONE deployment-side declaration. dsh 0.2.1 delivers a child's identity by SELECTING an agent preset, so a role-matched delegation selects what rolePresets maps that role to. Declare one complete preset per role (@deepseek-ai/dsh-agent-preset row whose plugins mount @deepseek-ai/dsh-persona with the role's persona text — persona, tools and skills all live in that composition) and list its id in the plugin's rolePresets. A role with no mapping keeps the delegating agent's composition — nothing breaks, the child simply carries no role identity. See Role identity delivery below.
Fresh-publish age window: pnpm's
minimumReleaseAgegate can make adsh plugin add <spec>range resolution pick an older published version (without the seeds surface) for up to ~24h after a fresh publish — re-runnpx @mstar-harness/cli init --target dshafter the window (or pin the version) to converge on the latest surface.
Headless profile (one-shot runs)
The plugin is not web-bound: it also mounts in the dsh headless profile (dsh --profile headless "<task>" — the one-shot, no-GUI/no-port mode). The install path is identical, one command:
dsh plugin --profile headless add @mstar-harness/dsh
The shipped headless template auto-initializes on first use (@deepseek-ai/dsh-base + @deepseek-ai/dsh-headless), and the reconcile step appends the mstar row to the bundle stack (dsh-base → dsh-headless → @mstar-harness/dsh). Every plugin capability rides a dsh-base seam that headless inherits — status/dispatch/lease/worktree gates, the skill mount, the engine-status catalog, the harness-rules system-prompt injection, the three native tools — so the harness is fully live in a one-shot run. Launch from the repo working directory (the runner writes meta.cwd = process.cwd() and the per-workspace harness-dir probe starts there). The browser client half is web-profile-only and simply does not load.
Headless usage caveats (dsh 0.1.0-rc.6 verified):
- One-shot turn model — the runner submits the task as one user message and exits when the agent goes idle (
whenIdle()); it does NOT awaitrun_in_backgroundchildren. Foreground subagent dispatch works (a child session is created and its result reaches the parent); background QC-tri-style parallelism does NOT complete in-process — either dispatch QC seats foreground (serial wall time) or have the agent collect background results withtool-subagent-controlBEFORE ending the turn. - No interaction channel —
ask_user_questionand approval prompts fail closed when no answerer is available. For unattended runs, keep a restricted permission preset (workspace-writewith approvalask, orread-onlyfor inspection) and verify the effective session permissions before starting work. Run only operations allowed within that boundary; if an operation needs approval, stop that operation and use the web profile to obtain it. Do not remove sandbox restrictions to bypass a missing approval channel. Interactive Prepare flows (grill-me) belong on the web profile. - Default model resolution — headless composes no fallbacks row, so an
agent-default-modelsettings pin ofFallbacksChainfails withNO_ADAPTER(the web-profile artifact). Point the default model at a real provider, or installdsh-llm-fallbacksinto the headless profile too (note: on the published dsh 0.1.0-rc.6 the fallbacks settings integration predates theSettingsProvider.installSectionAPI, so the virtual adapter does not register — this resolves with dsh ≥ 0.1.2-alpha). - Config via the profile user layer — profile-level
cordis.patch.ymloverrides work as documented (e.g.enforcement: hard+dispatchBindingon the mstar row); the mstar row's ownconfig: {}stays neutral.
Configuration
| Key | Type | Default | Meaning |
|---|---|---|---|
harnessDir |
string |
repo .mstarc [config] harness_dir, else per-session workspace probe (.mstar/ → .agents/ → .plans/ → plans/, from the session workspace root — never the launch cwd) |
Explicit harness root; wins over engine probing. Required for repos whose harness root is not a probed name; the probe starts from the session workspace root (never the launch cwd) and STOPS there — it never walks above the session workspace, so a harness dir above it (e.g. a global ~/.mstar) is never adopted. |
enforcement |
'hard' | 'soft' |
compass, else warn-only | Per-deployment override. Precedence: Config wins; else the Assignment's own **Enforcement**: hard header flag (dispatch gate only); else the repo .mstarc [config] enforcement; else the iteration compass frontmatter; else warn-only. Config / .mstarc soft are the ONLY local rollbacks — an Assignment-level soft does NOT override a hard compass. |
dispatchTools |
string[] |
['subagent', 'subagent_fork'] |
Delegation tool names the dispatch gate matches — the dsh preset's TWO delegation tools, subagent and its fork sibling subagent_fork (both carry Assignment-shaped { description, prompt } args; a toolName config may rename instances). |
dispatchBinding |
string |
unset — the caller is derived from the CALLING session | The dispatching agent's own harness role (the anti-recursion CALLER) — a ROOT-session identity. An Assignment whose Execute as equals the caller is self-recursion (dispatch.anti-recursion.self-type). The field is read ONLY when the calling session is the ROOT session (session.header.parentSession absent — in this harness the coordinating agent): there an explicit value (profile patch or settings form) wins verbatim, an explicitly blank value ('') fails closed, and an unset value reads as project-manager (an identity read, never a guess). A CHILD session — or a session the gate cannot read (the exec-less host beforeDispatch hook) — NEVER inherits it: the caller is EMPTY whatever the field says, so every Assignment-shaped dispatch fails closed with dispatch.anti-recursion.empty-binding, because a leaf must not dispatch and no configuration may lift that. Live-editable in the dsh plugin configuration form (same volatile path as roleGate). A deployment whose coordinator is NOT the root session cannot be served by this field. |
roleMap |
Record<string, string> |
unset | mstar role id (Execute as) → dsh-llm-fallbacks role id. A taxonomy bridge for logging + future rule-driven interop ONLY — never consulted by the role-preset channel (see LLM fallbacks integration). |
rolePresets |
Record<string, string> |
unset | mstar role id (Execute as) → deployed agent preset id; the role-preset channel's only payload source. A role-matched start selects that preset on the activation request (SubagentStartRequest.agentPreset), and dsh mounts it as the child's composition — persona (via @deepseek-ai/dsh-persona), tools and skills — replaying the selection on resume. The deployment declares one COMPLETE preset per role: a preset is a whole composition, never a persona-only patch. Merge order: a request that already carries agentPreset (the delegation tool's configured default, or the model's own agent_preset choice) wins AS-IS; otherwise the role is read from the Assignment header and a role with no entry stays silent. An empty/whitespace id fails Config validation at plugin mount. The selection is skipped (one contained debug log, start unaffected) when the provider does not support independent selection (capabilities.agentPreset false, or a provider that inherits its parent's conversation) or when the composition mounts no agentPresets registry (the web and headless bundles mount @deepseek-ai/dsh-agent-preset-registry; the base and SDK bundles do not). Configured ids are validated once per apply — an undeclared id gets one warning instead of surfacing later as a failed start. |
roleGate |
'off' | 'warn' | 'hard' |
'hard' |
Dispatch-role normalization at the subagent start seam (see Role identity delivery). An explicit declaration — an Execute as: / Act as: / Role: field line, or a backticked/bold already-declared role id, both IN THE ASSIGNMENT HEADER — is rewritten into the canonical Execute as: <role> header before the preset merge, so both role consumers bind it. hard refuses a start with NO reliable declaration, a start naming two or more DISTINCT roles (the message names the cause and the ids, the exact fix, and the escape hatch), a role field that IS present but unusable (empty, or not a lowercase role id — the message names the raw value and the accepted shape), and a canonical declaration that exists only inside a fenced code block (the header read is fence-unaware and the seam still binds that quote); warn logs ONE advisory per apply and starts unchanged; off skips the refusal only — a single explicit declaration is STILL normalized. Exemptions are judged at the child's EFFECTIVE start directory (an absolute cwd as-is, a relative one resolved against the parent's CURRENT directory — the working-directory projection the runtime itself resolves against, not the immutable header.cwd — and an omitted one inherited from it): a directory that resolves no {HARNESS_DIR} passes silently, as do prompts that already declare a usable role, requests carrying agentPreset, and unknown providers. This is one of the plugin's TWO LIVE-editable settings fields (roleGate and dispatchBinding): saving a different mode in the dsh plugin configuration form writes the profile patch and takes effect on the NEXT subagent start (no reload); every other field in this table is read once at apply and needs a reload. |
skillRoots |
string[] |
unset (no custom-root registration) | Additional skill roots registered with the dsh skill-filesystem provider (customSkillDirs semantics — scanned before user roots). Dev-time: the mirror <repo-root>/skills absolute path. |
bundledSkillDir |
string |
packaged harness-skills/ mirror (package-relative) |
Bundled skill root registered with the dsh skill-filesystem provider (bundledSkillDir semantics — scanned last, trusted). Defaults to the package's OWN harness-skills/ mirror (synced by bundle-assets; gitignored) — package-relative, NOT cwd-anchored. An explicit value wins. |
catalogTtlMs |
number |
60000 |
Pre-step catalog cache refresh interval (ms): how often the per-workspace unified mstar-engine catalog row (iteration gate + workspace-state digest) re-reads the store.db execution authority / the compass / the knowledge index. The hot path is a timestamp compare + cache hit between refreshes; a mid-session plan/compass/residual change lands within one interval. |
workflowGate |
'off' | 'warn' | 'ask' | 'hard' |
'warn' |
Workflow/ralph gate mode (see Gates → Workflow / ralph gate). off = pass-through with no verdict row; warn = advisory-only; ask = first-seen names route through the approval waterfall (P-c); hard = policy violations veto before any child starts. Default warn changes NO hard behavior — the gate is advisory-only unless the deployment opts into ask/hard. |
workflowNames |
string[] |
unset | Workflow name allowlist (P-a): meta.name values treated as KNOWN by the gate. Empty or absent ⇒ every name is unknown (documented — the gate is NOT "allow all" by omission). Ralph calls carry no meta.name — P-a never applies to them. Recommended for the mstar read-only fan-out path: ['mstar-qc-tri', 'mstar-audit-fanout', 'mstar-pr-seats']. |
Recommended operator overlay. The three recommended workflowNames above cover the mstar read-only fan-out path (plan QC tri, large-repo /codebase-audit, /amazing-pr-review deep); listing them is an operator overlay in the profile layer, never an mstar default. With the shipped empty list every name is unknown, which under the default workflowGate: 'warn' is one workflow.name.unknown advisory the run survives. A production deployment that also wants unknown names vetoed sets workflowGate: 'hard'; the shipped default stays 'warn'.
bundledSkillDir defaults to the package's OWN harness-skills/ mirror (see Skills mount) — an explicit Config value still wins. A relative override remains cwd-anchored (skill-filesystem join() semantics against the dsh process cwd), so deployments overriding the default should pass an absolute path in the profile layer (see bundle/README.md).
Composed row set
The profile bundle composes the following rows — the registry rows come from the @deepseek-ai/dsh-base layer, and this bundle's patch inserts the mstar row over them with neutral defaults (the row set the full-app e2e fixture boots):
- name: '@deepseek-ai/dsh-skill' # skill registry (ctx.skills) — dsh-base row
- name: '@deepseek-ai/dsh-tools' # tool registry (ctx.tools) — dsh-base row
- name: '@deepseek-ai/dsh-commands' # command registry (ctx.commands) — dsh-base row
- name: '@mstar-harness/dsh' # this bundle's patch insert (config: {} — plugin defaults apply)
The registry rows mount before the plugin so ctx.skills / ctx.tools / ctx.commands exist when the mstar gates, seam tools, and bundled commands register.
What the plugin does when mounted
- Status gate —
fs/write-intent+fs/edit-intentlisteners validate the v3 coordination-document target set — the v2 root{HARNESS_DIR}/status.json,workflows/<id>/snapshot.jsonandprojects/<id>/residuals.json— each with its matching engine validator (validateStatus= v2 root /validateWorkflowSnapshot/validateProjectRegister, the P2-fixedharnessDocKindOfTargetshape), plus the snapshot-targetfindingsCleanupGateextension per plan row that configures a mode (residuals read from the project registers). - Dispatch gate — a
tools/pre-executelistener on the delegation tool(s) validates subagent Assignment text through the engine's singlecomposeDispatchGatecomposition (field gate, anti-recursion precheck, default-branch gate — opencode/omp/CLI parity, so violation codes are identical by construction) plus the dsh lease gate and worktree L1/L2 checks. - Skill-authoring lint —
SKILL.mdwrites under the configured skill roots run the engine skill-authoring lints (lintFrontmatter+lintFiveQuestion). - Seam lints —
DESIGN.md/ review-report ({REVIEW_DIR}={HARNESS_DIR}/reviews/) / knowledge-doc / roles-dir writes under the harness get their artifact-specific engine lints. - Model-facing tools — exactly three tools register on
ctx.tools, and only for what the MCP surface cannot do:mstar_plan_bind(the durable-slot producer — the only writer of the dsh-localexecutionBindingslot and its adoption receipt),mstar_audit_validate(the review-report credential scan, which nomstar_auditaction publishes) andmstar_execution_clear(no sharedexecution.clearcommand exists). Every other runtime capability rides the MCP families, one tool per family with anactionargument. Store-level commands (store.*) are the exception on purpose: they move the whole store, so they stay CLI-only and their family members are published withtransport: "cli". The membership rule and each tool's unique contribution: § Native command surface. - Bundled commands —
ctx.commandsregistrations for/iteration-start,/iteration-drive,/iteration-loop,/codebase-audit(from the packagedharness-commands/mirror; each declares a frontmatterinputhint so the web client claims/nameand waits for the user's follow-up args instead of executing immediately; handlers steer the command body + user args into the receiving agent). - Native execution binding path — the durable execution binding is adopted automatically by a successful native
mstar_plan_bind(both bind arms, each after the engine's read-only C1 session resume succeeds) and cleared on demand with themstar_execution_cleartool: identity comes fromexec.agent.id(no parameters, nothing model-choosable), the clear preserves the session's own user selection and exclusion floor, and an unbound session or an unreadable binding record gets a named refusal with its recovery instead of a silent write. There is no slash command on this path — to bind or clear an execution session an operator uses exactly these two native tools. - Pre-step catalog row — an advisory
agent/pre-steplistener appends ONE unifiedmstar-enginecatalog message carrying ONE delta line (workflow/ non-Doneplans/branchanchors / phasegateverdict when a steering compass resolved) plus theworkflow warningdisclosure when the selection carries one. The residuals rollup, branch policy, spec-integration anchor, row scopes, knowledge digest, compass direction and agent-flow ledger are payload-only (panel + engine authorities) — § Engine-status catalog. Emission is change-driven per session+workspace — first emission, then only when the rendered row or the panel-relevant payload changes; the start of a turn is never a trigger, and a workspace with no resolved{HARNESS_DIR}composes no row. The build behind it shares one in-memory TTL cache (catalogTtlMs, default 60 s); the newest per-session emission persists in the control-rootstore.db(dsh_status_emissions), not a file cache.
Enforcement semantics
Warn-only by default: gate violations log and emit advisory events (mstar/status-gate, mstar/dispatch-gate, mstar/skill-lint, seam advisories) and the action proceeds. Enforcement: hard — from the iteration compass frontmatter, the Assignment header, or the plugin Config (enforcement: hard) — escalates violations to a real veto/deny through the cordis refusal channels: subagent dispatch returns PreToolDecision { kind: 'deny', reason } without delegating; status/skill-lint writes are never hard-vetoed because the intent waterfall is content-blind — an already-invalid document is allowed as a repair escape (hard: true, repair: true advisory) so the repairing write can land. Config soft is the only local rollback; hard gates are never a global default.
Gates
Status gate
fs/write-intent + fs/edit-intent listeners (registered prepend so they run before dsh-fs-policy) gate writes to the v3 harness coordination-document target set — the v2 root status.json, workflows/<id>/snapshot.json and projects/<id>/residuals.json — each validated by its matching engine validator (harnessDocKindOfTarget classification; the snapshot kind additionally runs the findingsCleanupGate extension per plan row that configures a mode, over the project registers), against the current on-disk document (parsed exactly once — no TOCTOU double read). The gate never throws: every decision surfaces as the mstar/status-gate advisory and the intent waterfall is delegated via next(). Warn mode (default) logs + emits on violations. Hard mode allows an already-invalid document as a repair escape (error-level log + advisory with hard: true, repair: true) — the intent waterfall carries no incoming content, so a hard veto on an invalid document would deadlock the very write that repairs it. Unexpected internal errors degrade to allow in BOTH modes with a degraded: true advisory (error-containment envelope); the corrupting write itself cannot be vetoed on this seam (see Known Limitations).
Dispatch gate
tools/pre-execute listener on the delegation tool(s): parses the payload's Assignment text and runs the engine's SINGLE dispatch-gate composition (composeDispatchGate — shape guard, validateAssignmentFields, antiRecursionPrecheck, default-branch gate, header-region enforcement; the same composition the opencode/omp/CLI bindings use, so violation codes are identical by construction) over the header region, plus the dsh-side worktree L1/L2 checks and the lease gate. The refusal channel is PreToolDecision { kind: 'deny', reason } returned without calling next(); warn mode logs, emits mstar/dispatch-gate, and delegates. Non-Assignment prompts and non-delegation tools are inert. Engine failures degrade to allow in both modes observably: the catch path emits the plugin-owned advisory with degraded: true + an error log, so a hard deployment can detect a dead control instead of a silent pass. Registered prepend so an earlier-mounted decision can never…
Lease gate
Additive beyond the opencode fields: writable Assignment dispatch with Execution mode: sdd or an InProgress row compares Worktree path/Working branch to authoritative row metadata.worktree_path/metadata.working_branch, never a holder or per-row lease. Missing recorded scope is lease.dispatch.unverifiable (STOP before writable dispatch). Missing/unreadable authority/status/snapshot/row still produces lease.dispatch.unverifiable or lease.dispatch.unreadable; warn is advisory, hard denies. Worktree/branch mismatches retain lease.dispatch.worktree-mismatch/branch-mismatch; holder-mismatch is removed. Read-only roles skip it; other non-SDD/non-InProgress dispatch remains degrade-allow. Reads stay in assignmentHeaderRegion.
Skill lint gate
fs/write-intent listener scoped to SKILL.md files under the configured skill roots runs the engine skill-authoring lints (lintFrontmatter + lintFiveQuestion — the CLI mstar skill lint combination) on the pre-write on-disk document. The slot is content-blind (the intent waterfall carries only (target, actor)), so: missing file = first create = pass; clean on-disk doc = silent pass; violations in warn mode = advisory + delegate; violations in hard mode = repair escape — the document is ALREADY invalid, so this write may BE the repair (error-level log + hard: true, repair: true advisory with the enforced hardBlocked verdict). Enforcement resolves like the other gates (Config override, else the iteration compass, else warn-only). The gate never throws; read failures and unexpected errors degrade to allow with a degraded: true advisory. The typed hard veto (SkillLintVetoError, code skill-lint.veto) lives on the incoming-document branch (lintSkillWrite) — see Known Limitations for its current wiring.
Workflow / ralph gate
A tools/pre-execute branch (BEFORE the subagent prompt branch) gates the workflow and ralph tool calls — the remaining model-reachable fan-out that carries no Assignment text. It matches the FIXED tool names (workflow / ralph); a renamed workflow instance is out of scope (the name guard is the fixed default). Non-workflow tools are untouched — the subagent branch owns them, semantics unchanged.
Four-tier workflowGate remains independent of Enforcement: off/warn/ask/hard. P-b checks recorded writable scope first, then P-a names and P-c first-seen approval; no per-row holder qualification.
| Policy | off |
warn (default) |
ask |
hard |
|---|---|---|---|---|
| P-b: an InProgress row lacks metadata.worktree_path/metadata.working_branch | allow (off) | warn/advisory, workflow.lease.uncovered | warn/advisory; ask is for names only | deny before child start, workflow.lease.uncovered, naming row |
P-a: workflow name ∈ workflowNames (non-empty list) |
allow (short-circuit) | allow — no advisory (P-a passes under every mode) | allow — no ask | allow |
| P-a: workflow name unknown (empty/absent list ⇒ every name unknown) | allow (short-circuit) | warn — allowed + advisory (workflow.name.unknown) + one warn |
ask (first-seen) → {kind:'ask'}; the cached decision (allow/deny) is reused afterwards — never a re-ask for a resolved name |
deny — veto before any child starts (workflow.name.unknown), reason names the name |
ralph (no meta.name — no allowlist identity) |
allow (short-circuit) | allow — P-a/P-c NEVER apply | allow — P-a/P-c NEVER apply | allow — P-a/P-c NEVER apply; P-b still applies (deny when uncovered) |
Default-warn rationale. warn is the default so the gate never surprises a deployment into a hard block: it is advisory-only unless the operator opts into ask (human ask channel) or hard (veto). An empty/absent workflowNames makes every name unknown — the gate is not "allow all" by omission, but the default mode turns that into an advisory, not a block.
Interaction with Enforcement: hard. The workflow gate's mode is its OWN Config knob — the cross-cutting Enforcement: hard resolution (compass / Assignment header / Config enforcement) does NOT escalate workflowGate. A hard-enforcement deployment still runs the workflow gate in its configured mode (default warn = advisory-only) unless it also sets workflowGate: 'ask' or 'hard'; conversely workflowGate: 'hard' vetoes regardless of the cross-cutting resolution. The two must not be confused: the workflow gate closes the "ungated fan-out under Enforcement: hard" gap only when the deployment opts its mode in.
Fail-open edges (documented, never crash a compliant call). (1) Malformed args — a workflow call without a non-empty string meta.name (after control-char normalization), or a ralph call without a string objective → pass-through + ONE warn under every mode (hard included), and NO verdict row (no policy verdict was produced). A name that is only control characters normalizes to empty → malformed. (2) Unreadable status.json — the P-b status read through the contained resolver path throws → P-b is degraded for that call only with ONE warn; P-a/P-c (name-based, no status dependency) still run. The gate NEVER throws: every read is structural.
Verdict ledger rows. Every gated call records ONE durable workflow-verdict row in the agent-flow ledger (the P2 ledger plan's record path, fully contained — a failing ledger write never reaches the gate): tool (workflow | ralph), workflow (normalized meta.name) or objective, mode (never off — off short-circuits before the policy), and the verdict vocabulary ok / advisory / denied / ask (the ask verdict is the extension: a first-seen ask is itself a gated call, so its row carries ask until the approval waterfall resolves it — "one ledger row per gated call"). Violation codes come from the verdict, never guessed: workflow.name.unknown (P-a) vs workflow.lease.uncovered (P-b). Fail-open paths (malformed args / unreadable status) record nothing; calls with no resolved harness dir skip the row (same silent no-op as the dispatch record path).
P-c answer-observation seam. The gate cannot observe the ask outcome — the tool registry's serviceAsk consumes the approval result internally. The run-start observation IS the answer seam: an ALLOWED ask executes the call → the durable tool-workflow/run-start session event lands in the parent session log → the workflow-ledger consumer records the W-B2 workflow-run row AND caches allow for the run's name into the apply-scoped WorkflowAskCache. A DENIED answer produces no run → no observation → the next same-name call under ask re-asks (fail-closed — no grant evidence, never an invented allow). Cache keys are the normalized (ASCII control chars stripped) uncapped name at BOTH seams — the gate composes meta.name and the observation records runName through the SAME normalizeWorkflowName, so a control-char name (au\u0000dit) can never wedge the cache (asks once, observes under the same key), and a >1024-char name still keys on the full name (the ledger ROW display name is capped separately; the identity axis is never truncated). The cache is apply-scoped — a fresh apply (HMR reload) starts empty, so an unresolved first-seen re-asks per call until an observation (or an explicit record()) lands. A throwing cache record degrades the observation with one warn — the ledger row is already appended, the run is never affected.
P-b preemption: missing recorded source scope on an InProgress row precedes name policy and applies to ralph. Configure actual source facts through ordinary prepare before writable dispatch; no per-row recovery/lease claim. Warn/ask remains advisory-only, hard vetoes.
LLM fallbacks integration
The optional dsh-llm-fallbacks plugin (installed with the second command — see Install paths) powers role-based subagent configuration — the role seeds and the adoption advisory; role identity delivery itself rides the NATIVE subagent preset channel (independent of fallbacks, see below). The mstar plugin carries zero runtime AND zero type references to the package — src/ has no import of it (runtime or type); the consumed service surface is mirrored by the local structural types in src/gates/fallbacks-structural.ts, and the package itself is a dev-time-only dependency of the mstar plugin (type mirroring + the real-package test harness). dist/ names the package only in ONE string literal — the probe's loader-entry match — never an import or a type reference; the interop is a decision-point capability probe, never a module-internals read.
Capability probe
Two views over the mounted state (point-in-time reads at decision points, no cache — loader mounts entries concurrently):
fallbacksService(ctx)— the named cordis service (ctx.get('llm-fallbacks')) while the plugin is applied;undefinedduring HMR/fiber-swap windows even when the loader entry lives (the entry is declarative and outlives a fiber swap).fallbacksMounted(ctx)— capability view, service-first with a loader-entries fallback: the loader entry nameddsh-llm-fallbacksis present, enabled (respectingentry.disabledand group rows), and has a live fiber.
Distinct states: mounted (service applied — full capability), unmounted (no entry: the fallbacks plugin was not installed — the mstar capability degrades, never breaks), disabled (entry present but disabled/grouped — capability off), and the HMR window (entry lives, service absent — the loader fallback covers it).
Role identity delivery (native subagent preset channel)
Role identity is delivered by SELECTING a deployed agent preset: the plugin intercepts the ctx.subagents service READ through the cordis internal/get waterfall — the framework's documented service-read interception hook — and wraps the runtime value so a role-matched startActivation(spec) merges spec.request.agentPreset BEFORE the child is composed. startActivation is the ONE start surface in dsh 0.2.1 (one-shot and continuable children alike), and the preset owns the child's whole composition (persona, tools, skills); dsh records the selection in the child's log and replays it on resume. The underlying SubagentRuntime object is never mutated (no monkey-patching), and the wrapping listener is fiber-scoped (an HMR re-apply unwinds and restores it, re-binding the fresh Config). Persona delivery rides dsh 0.2.1's design, not a private section: SubagentStartRequest.persona and the start/startContinuable pair were REMOVED upstream, and tool-subagent rejects a persona config with "configure persona and tool plugins in an Agent Preset and select it with agentPreset (spawn only)".
Capability gate: an independent selection requires a provider whose SubagentCapabilities.agentPreset is true and whose inheritsParentContext is false — the same pair upstream's tool-subagent asserts ("provider does not support independent agent preset selection"). The agentPresets registry must also be mounted: without it dsh fails loud PRESET_UNAVAILABLE, so the channel skips the selection with ONE debug log (latched per apply) and the start proceeds unchanged. Gate misses never fail a dispatch.
Deployment-owned compositions: mstar owns only the role → preset SELECTION. Every preset a role names is declared by the deployment (@deepseek-ai/dsh-agent-preset rows, typically mounting @deepseek-ai/dsh-persona with the role's persona text, mirroring upstream's own standard/minimal presets); mstar ships no generated presets because a preset is a complete composition — built from rows mstar does not own.
Normalize first, refuse last (roleGate): the same wrapper normalizes an explicit role declaration BEFORE the preset merge — a Role: \fullstack-dev`line, anAct as:value (after trimming one leading@, quotes or backticks), or a backticked/bold token that is an already-declared role id (rolePresetskeys ∪ the packagedharness-agents/shell stems) — all read in the Assignment header — is rewritten into the canonicalExecute as: header, and the rewritten prompt is what the inner consumer and the runtime receive. Both candidate tiers are header-scoped and read the fence-masked text: a role id quoted in a body sentence or a fenced example is never a candidate (so pasted prose or a template cannot arm a role), while a canonical declaration the header parser reaches ONLY inside a fence is refused underhardinstead of passing silently — the header read is fence-unaware and the seam binds that quote. EveryExecute as field line in the header is repaired and its label normalized, because the parsers bind the LAST field line case-sensitively; two or more DISTINCT candidates are ambiguous and are never guessed — that refusal names the ids and says exactly one declaration is required (free prose, "You are an architect doing…", is never mined). Only a start with no reliable declaration (or an ambiguous, fenced, or present-but-unusable one — empty value, or not a lowercase role id) is refused (hard, the default), and the message names the actual cause and raw value, the exact fix and the mode-appropriate recovery: the hardrefusal offersroleGate: warn|off, while the warnadvisory only suggestsroleGate: off(it already proceeded). A non-harness project — judged at the child's EFFECTIVE start directory (an absolutecwdas-is, a relative one resolved against the parent's CURRENT directory, an omitted one inherited from it, exactly the way the runtime's ownresolveDirectorypicks it, so the base is the working-directory projection rather than the immutableheader.cwd) — is exempt, as are prompts that already carry a usable Execute as, requests carrying agentPreset, and unknown providers (the service fails its own way). off` skips only the refusal; a single explicit declaration is still normalized.
roleGate and dispatchBinding are editable from the plugin configuration UI — both are volatile schema nodes read through the Loader's live reference, so a settings save writes the profile patch and applies without a remount.
Anti-recursion binding (dispatchBinding) is a ROOT-session identity, never inherited — the caller is derived from the CALLING session, and the field is read only there. A ROOT session (session.header.parentSession absent) IS the harness's coordinating agent: an explicit binding (profile patch or settings form) wins verbatim, an explicitly blank one still fails closed, and without an explicit value it reads as project-manager — an identity read, never a guess. A CHILD session (any parentSession) never inherits the binding: the caller stays EMPTY whatever the field says, so every Assignment-shaped dispatch fails closed with dispatch.anti-recursion.empty-binding — exactly the pre-default behaviour, because a leaf must not dispatch. A session the gate cannot read (the exec-less host beforeDispatch hook) falls back to EMPTY too, never to the coordinator and never to the configured value. The declared or derived caller is still a real comparison: an Execute as equal to it is refused as self-recursion (dispatch.anti-recursion.self-type), and the explicitly blank state logs one warn at apply pointing at the plugin settings form. A deployment whose coordinating agent is not the root session cannot be served by this field.
Seam probe (fail-loud): the channel probes the cordis internal/get seam once per apply (temporary canary listener + one controlled proxied ctx.subagents read — never ctx.get); a missing or unrecognized seam logs ONE warn — role persona channel not installed — cordis 'internal/get' seam missing or unrecognized (role presets will not be selected for subagent starts) + (reason: seam-absent | wrap-skipped) — instead of failing silently. A healthy boot produces NO warn (the locked outcome table): on today's composition the apply ctx does not resolve subagents, so each apply emits one no-warn service-absent debug — cordis resolves the service in dispatch scopes, where reads are intercepted per read; probe-internal errors degrade fail-open (one debug). wrap-skipped is pinned but not reachable from the shipped apply wiring — it fires where subagents is resolvable at apply, a path covered by the harness pins. The seam name is pinned by the dedicated probe family tests/persona-seam-probe.spec.ts (via the exported PERSONA_SEAM_EVENT constant); the installed-artifact assertion arms once the release carrying the probe publishes (surface-vintage guard in tests/install-e2e.spec.ts).
Role seeds + adoption advisory
When the optional dsh-llm-fallbacks capability is mounted (the second install command — see Install paths), the mstar plugin zero-config declares the 13 mode: subagent mstar role seeds into the fallbacks seed registry: persona = the harness-agents/ mirror description (verbatim) + one mandatory-load guide line (Load mstar-roles (references/<role-id>.md) first — identity comes before skills; load topic skills only when the Assignment activates them via its Skill presets field.); a persona carrying the {{...}} interpolation hazard is skipped + warned, never declared. The declaration merge-preserves the currently-seeded non-mstar ids from the readback — e.g. the 5 upstream preset roles the upstream package self-declares at its own apply: upstream declare REPLACES the whole registry, so without preservation a mstar-only batch would strip preset ids of their seeded annotations (rows remain, unseeded). The declaration re-fires idempotently on every fallbacks (re-)apply (HMR/fiber swap) — never from a one-shot latch — so either boot order (presets first or mstar first) converges to the same 18-id fully-seeded registry. Boot-time convergence is a bounded retry: the provider's seed write channel binds one macrotask after its apply, so a first-attempt reject with seeds: settings service is unavailable inside that apply window is retried (3 attempts across the provider's apply window) and a transient reject converges on its own; only if every attempt ultimately fails does the declaration log exactly one terminal error, while the advisory's decision-point re-declare stays available as the retry path. No manual roles.list edit is required.
A warn-only advisory pass (logger mstar/fallbacks-advisory) runs once per apply — attempted at apply and, when the fallbacks row mounts after dsh (the loader mounts entries concurrently), once at the first subagent/start decision point. With the service present, the pass FIRST awaits the idempotent re-declare (closing the boot race) then reads the EFFECTIVE state (getEffectiveRoles) and reports, bounded to at most one warn per category:
- missing mstar roles — an mstar id with no effective row (one warn listing them; the id set is derived from the
harness-agents/mirror — never hardcoded; no mirror → the check is skipped with one debug); - persona overrides — an mstar role whose row persona differs from the seed default (one warn naming them + the revert entry: the
fallbacks/revert-seedgateway / the fallbacks settings-card rollback button — the operator override is retained until reverted); - empty personas — rows with a missing/blank persona, only when non-seeded or overridden-empty (one warn naming them);
- legacy keys —
chains,roles.default,roles.list[].label/.description, danglingroles.rules[].rolereferences, via the applied service's owndetectLegacyKeys(one warn citing its semantics); - declare skips/conflicts — local skips (
interpolation/no-persona) + upstream skips/conflicts (codepersona-source— operator override retained) merge into ONE warn; seeded-at-default is silent (one debug naming the ids).
On the loader-fallback path (no service) the structural roles.list read is preserved (missing ids / empty personas; no revert entry — no seeds surface; the legacy-keys check is skipped — never reimplemented). A row config that is absent or not an object, or an unreadable roles.list, skips the pass with one debug log. The advisory never writes the fallbacks config — the only write path is the idempotent seeds re-declare through the released seeds surface (no-delta → no settings write upstream) — never throws, and is not invoked when fallbacks is unmounted: it is a signal, not a gate.
Config surface
| Key | Type | Default | Meaning |
|---|---|---|---|
roleMap |
Record<string, string> |
unset | mstar role id → fallbacks role id. Taxonomy bridge for logging + future rule-driven interop only — unused by the role-preset channel by design. |
rolePresets |
Record<string, string> |
unset | mstar role id → deployed agent preset id; the channel's only payload source (the selected preset owns the child's persona, tools and skills). |
Service
apply constructs ctx.dshMstar (engine-backed: validateStatus, validateResidual, findingsCleanupGate, resolveCompassEnforcement, resolveHarnessDir, readHarnessVersion, applyEnforcement). Layering: the P1 gates are co-located engine wrappers in this package importing the engine directly (same plugin, engine bundled at build time); ctx.dshMstar is the composition/test façade for inject consumers; the host adapter (below) is the host-facing facade. The engine is the single grammar for both paths. The companion entry @mstar-harness/dsh/invariant reserves package ownership with a documented no-op installer.
Host adapter
The plugin implements the engine HostAdapter contract (host: 'dsh') as DshHostAdapter, exposed as the ctx.dshHostAdapter service. Detection: the engine detectHost maps the dsh delegation tool name — ToolSignal subagent (the model-facing dsh subagent tool) — to 'dsh', evaluated after omp and before kimi/zcode/codex; hybrid sessions lose to earlier rows by fixed order. The adapter routes through the SAME validation cores as the in-plugin gates (one code path): beforeStatusWrite(path, doc) validates the incoming document when the host provides it, else the on-disk fallback (missing file = first create = pass); beforeDispatch(assignment) runs the field + branch + anti-recursion gate with the enforced hardBlocked verdict (the lease gate stays listener-side — it binds the ToolExecution session context the hook does not carry); beforeMerge(lease) is a thin wrapper over the engine validateIntegrationMergeLease (the reservation write into status.json is a P3 seam). log
…