Workflow & Automation

mstar-harness

btspoony/mstar-harness · ★ 67

btspoony

Skill-driven harness/loop engineering workflow agent plugin.

License
MIT
Added
2026-08-14
Maintenance
active
Install kind
github
Manifest status
Declared
Manifest path
package.json#dsh
Manifest checked
2026-08-19
Lifecycle scripts
None found

Static manifest checks report declarations only; they are not compatibility or security review.

安装

dsh plugin --profile web add github:btspoony/mstar-harness#path:/packages/dsh

GitHub 源码安装:首次需按提示配置 allowBuilds 构建授权后重试

For install and environment setup, plus plugin development tutorials, see DSH community docs ↗

README

Source README · body: en · Read Chinese README

Table of contents
  1. Usage
  2. Install paths
  3. Headless profile (one-shot runs)
  4. Configuration
  5. Composed row set
  6. What the plugin does when mounted
  7. Enforcement semantics
  8. Gates
  9. Status gate
  10. Dispatch gate
  11. Lease gate
  12. Skill lint gate
  13. Workflow / ralph gate
  14. LLM fallbacks integration
  15. Capability probe
  16. Role identity delivery (native subagent preset channel)
  17. Role seeds + adoption advisory
  18. Config surface
  19. Service
  20. Host adapter

English | 中文

dsh

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 minimumReleaseAge gate can make a dsh plugin add <spec> range resolution pick an older published version (without the seeds surface) for up to ~24h after a fresh publish — re-run npx @mstar-harness/cli init --target dsh after 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 await run_in_background children. 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 with tool-subagent-control BEFORE ending the turn.
  • No interaction channel — ask_user_question and approval prompts fail closed when no answerer is available. For unattended runs, keep a restricted permission preset (workspace-write with approval ask, or read-only for 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-model settings pin of FallbacksChain fails with NO_ADAPTER (the web-profile artifact). Point the default model at a real provider, or install dsh-llm-fallbacks into the headless profile too (note: on the published dsh 0.1.0-rc.6 the fallbacks settings integration predates the SettingsProvider.installSection API, 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.yml overrides work as documented (e.g. enforcement: hard + dispatchBinding on the mstar row); the mstar row's own config: {} 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-intent listeners validate the v3 coordination-document target set — the v2 root {HARNESS_DIR}/status.json, workflows/<id>/snapshot.json and projects/<id>/residuals.json — each with its matching engine validator (validateStatus = v2 root / validateWorkflowSnapshot / validateProjectRegister, the P2-fixed harnessDocKindOfTarget shape), plus the snapshot-target findingsCleanupGate extension per plan row that configures a mode (residuals read from the project registers).
  • Dispatch gate — a tools/pre-execute listener on the delegation tool(s) validates subagent Assignment text through the engine's single composeDispatchGate composition (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.md writes 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-local executionBinding slot and its adoption receipt), mstar_audit_validate (the review-report credential scan, which no mstar_audit action publishes) and mstar_execution_clear (no shared execution.clear command exists). Every other runtime capability rides the MCP families, one tool per family with an action argument. 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 with transport: "cli". The membership rule and each tool's unique contribution: § Native command surface.
  • Bundled commands — ctx.commands registrations for /iteration-start, /iteration-drive, /iteration-loop, /codebase-audit (from the packaged harness-commands/ mirror; each declares a frontmatter input hint so the web client claims /name and 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 the mstar_execution_clear tool: identity comes from exec.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-step listener appends ONE unified mstar-engine catalog message carrying ONE delta line (workflow / non-Done plans / branch anchors / phase gate verdict when a steering compass resolved) plus the workflow warning disclosure 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-root store.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; undefined during 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 named dsh-llm-fallbacks is present, enabled (respecting entry.disabled and 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-seed gateway / 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, dangling roles.rules[].role references, via the applied service's own detectLegacyKeys (one warn citing its semantics);
  • declare skips/conflicts — local skips (interpolation / no-persona) + upstream skips/conflicts (code persona-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

…