dshbase

插件目录 / Developer / dsh-plugin-hooks-ordering

dsh-plugin-hooks-ordering

未验证 xinyuehtx

✓ 持续维护 基于 1 个官方 DSH 包

查看 GitHub ↗ ← 返回插件目录

1Stars
0Forks
0未关闭 issue
语言
2026-08-17最近推送
跨平台平台

功能简介

为 deepseek harness 的 waterfall 和 serial 进行确定性 hooks 监听排序

我们的评价
未验证 — 尚未实测

为 deepseek harness 的 waterfall 和 serial 进行确定性 hooks 监听排序 尚未验证——请自行安装测试。

「未验证」表示我们的自动化 CI 尚未安装过该插件。功能描述与版本兼容性均为作者声明。这不是安全审计,也不代表对第三方代码的背书。

你是插件作者? 想拿到「已验证」标签——提交你自己的验证证据(截图、日志或短视频),我们审核通过后即改为「已验证」。

提交验证证据 ↗

README

dsh-plugin-hooks-ordering

English | 简体中文

Deterministic before/after ordering for Cordis hooks whose participants are contributed by independent, mutually-unaware plugins — for both waterfall and serial dispatch, with an optional DeepSeek-Harness layer that controls real dsh hooks out of the box.

  • Playground (live): https://xinyuehtx.github.io/dsh-plugin-hooks-ordering/ — interactive, and runs a real Cordis context in your browser (real dispatch, real services), not a simulation.
  • Runnable demo: pnpm run demo (generic Cordis) and pnpm run demo:dsh (real dispatch on real dsh hook names, with the DAG log written to a file).

The problem

Cordis dispatches waterfall listeners in registration order — their position in the internal listener array, with prepend as the only lever. Registration order is in turn driven by inject-dependency activation, which is non-deterministic between unrelated plugins.

The consequence: a plugin cannot reliably state a relative order such as "run me after that other plugin." Concretely:

  • There is no before/after/stage declaration on ctx.on — only a boolean prepend, and prepend is last-prepender-wins, so it is not a stable "always first" either.
  • The only robust ordering primitive Cordis offers is inject: if plugin B injects a service plugin A provides, B activates after A. That pins order globally and single-directionally, and forces B to depend on A — impossible across vendors that must not depend on each other.
  • Two plugins may need opposite relative order on different hooks. A single activation order cannot express that.

So ordering that matters — auth before logging, sanitizer before serializer, metrics last — silently depends on load order that nobody controls. Change an unrelated inject, and the order flips.

raw ctx.on, load order [auth, logging, metrics]  ->  auth, logging, metrics
raw ctx.on, load order [metrics, logging, auth]  ->  metrics, logging, auth   ← same plugins, flipped

This is not hypothetical. In deepseek-harness the waterfall hook agent/pre-step is subscribed by a dozen+ independent packages, tools/post-execute by six, llm/stream by five — and several places document that the relative order is load-bearing yet decided only by prepend and registration timing (see How ordering is done in dsh today).

The solution

You do not modify or fork Cordis. This package is an ordinary Cordis plugin that brackets a chosen hook and decides participant order itself, independent of plugin load timing.

Waterfall (HookOrdering) brackets the hook with a single prepended listener and exploits the onion model:

  • Code before the listener's next() runs ahead of the entire native chain — the front phase.
  • Code after next() runs behind everything, including the hook's built-in default — the back phase.

Serial (SerialHookOrdering) has no next() to wrap, so it brackets with two coordinators: a prepended front coordinator that runs ahead of the native chain (a bail there short-circuits the whole dispatch), and an appended back coordinator that runs best-effort last.

In both, participants register into the coordinator (not the raw hook) with before/after names, and a stable topological sort decides their order.

HookOrdering, load order [auth, logging, metrics]  ->  auth, logging, <host default>, metrics
HookOrdering, load order [metrics, logging, auth]  ->  auth, logging, <host default>, metrics   ← stable

Why this belongs in a plugin, not in Cordis

Cordis is deliberately minimal: it provides ordering primitives (array position, prepend, the next() chain). Ordering policy — numeric orders, before/after, topological sort — differs per hook and is not a kernel concern. Building it as a plugin means zero framework modification and no fork to maintain.

Layers

The package is layered so you can use it at the altitude you need:

Layer Entry point What it gives you
1. Algorithm @tengxiaohtx/dsh-plugin-hooks-ordering/topo-sort, /dag The pure stable topological sort and the constraint-graph (JSON) renderer. Zero dependencies, no Cordis.
2. Cordis services @tengxiaohtx/dsh-plugin-hooks-ordering (root), /waterfall, /serial HookOrdering and SerialHookOrdering — control any hook in any Cordis app.
3. DeepSeek-Harness @tengxiaohtx/dsh-plugin-hooks-ordering/dsh A dsh plugin + cordis.patch.yml that controls the real dsh hooks for you.

Install

pnpm add @tengxiaohtx/dsh-plugin-hooks-ordering
# peer dependency:
pnpm add @deepseek-ai/cordis

Usage

Waterfall hooks

import HookOrdering from '@tengxiaohtx/dsh-plugin-hooks-ordering'

ctx.plugin(HookOrdering)

// The hook owner (or app composition) takes control ONCE. This installs the
// single bracket listener; controlling twice throws, so the prepend race
// cannot come back.
ctx.hooksOrdering.control('request/assemble')

// Vendor A — names only its own constraint, imports nothing from vendor B.
ctx.hooksOrdering.register('request/assemble', 'front', {
  name: 'auth',
  before: ['logging'],
  run: (req) => authenticate(req),
})

// Vendor B — a different package, unaware of A.
ctx.hooksOrdering.register('request/assemble', 'front', {
  name: 'logging',
  run: (req) => log(req),
})

// Vendor C — must run after everything, even the host default.
ctx.hooksOrdering.register('request/assemble', 'back', {
  name: 'metrics',
  run: (req) => emitMetrics(req),
})

Regardless of the order these three plugins load or register, auth runs before logging, and metrics runs last.

Logging the constraint DAG

Pass a log file to write the constraint graph (JSON) on every registration change, so it always reflects current state — handy when debugging an unexpected order or a cycle:

ctx.plugin(HookOrdering, { log: './hooks-ordering-dag.json' })
{
  "sections": [
    {
      "hook": "request/assemble",
      "phase": "front",
      "nodes": ["auth", "logging"],
      "edges": [{ "from": "auth", "to": "logging" }]   // auth runs before logging
    },
    {
      "hook": "request/assemble",
      "phase": "back",
      "nodes": ["metrics"],
      "edges": []
    }
  ]
}

The graph is rendered without topological sorting, so a cycle is shown faithfully rather than throwing. You can also read it programmatically at any time via ctx.hooksOrdering.dumpDag() (returns the JSON string). Write failures are reported via console.warn and never thrown back into the fiber.

Serial hooks

import { SerialHookOrdering } from '@tengxiaohtx/dsh-plugin-hooks-ordering'

ctx.plugin(SerialHookOrdering)
ctx.serialHooksOrdering.control('turn/stopping')

// front: runs ahead of the native chain. Returning a bail value (anything but
// null/false/undefined) short-circuits the whole serial dispatch.
ctx.serialHooksOrdering.register('turn/stopping', 'front', {
  name: 'guard',
  run: (turn) => (isAllowed(turn) ? undefined : 'DENIED'),
})

// back: best-effort last (see Semantics and limits).
ctx.serialHooksOrdering.register('turn/stopping', 'back', {
  name: 'audit',
  run: (turn) => recordAudit(turn),
})

In DeepSeek-Harness

The /dsh entry is a dsh plugin that mounts both services and takes control of the dsh hooks that multiple packages contribute to (agent/pre-step, tools/pre-execute, tools/post-execute, system-prompt/assemble, llm/stream, … and the serial agent/turn-stopping). Add one row to your profile patch:

- insert:
    - id: hooks-ordering
      name: '@tengxiaohtx/dsh-plugin-hooks-ordering/dsh'
      config:
        # hooks: ['agent/pre-step', 'tools/post-execute']   # default: all known dsh waterfall hooks
        # serialHooks: ['agent/turn-stopping']              # default: [agent/turn-stopping]
        # log: './hooks-ordering-dag.json'                  # optional DAG log

Controlling a hook with no registered participants is a transparent pass-through, so this row changes nothing until a plugin registers with before/after. A package cordis.patch.yml is shipped at the root (declared via the dsh.bundle.patch manifest field) with the same row.

Recommended assembly order

Ordering is enforced by the coordinator, not by load position — but the coordinator's bracket must be prepended after the native listeners are registered, so mount this plugin last:

  • The plugin brackets each controlled hook with a single prepended listener; prepend places it ahead of every listener registered so far. Mounted last, its next() encloses the whole native chain — front runs before all native listeners, back after all of them (and after the host default).
  • Mounted earlier, a native plugin that registers later with { prepend: true } lands ahead of the bracket and escapes ordering — it "overwrites" the coordinator's placement.

Contributors are unaffected by load order: they register() into the coordinator with before/after names rather than racing on ctx.on, so they never compete for the prepend position.

In a dsh profile this means placing the hooks-ordering row in the user cordis.patch.yml, which is applied after every bundle layer — so the plugin loads last by construction. See examples/dsh-profile for a complete profile.

How ordering is done in dsh today (and the limits of each)

These are the existing ways to influence hook order in deepseek-harness — the "bypasses" this plugin supersedes. Each is real and each falls short of a declarative relative order:

  1. { prepend: true } on ctx.on(...) — the engine's only placement lever (unshift vs push, vendor/cordis/src/events.ts:143). It is binary and last-prepender-wins: two plugins that both prepend race, and neither can say "first among the front." Used e.g. by packages/spill/spill-policy/src/index.ts:209 and packages/llm/llm/src/invariant.ts:88.
  2. Registration / ctx.plugin(...) call order — default append makes call order the execution order. It works only while one composition site controls every call, and breaks the moment independent vendors load in an order nobody owns. Relied on deliberately (and fragilely) in packages/skill/tool-skill/src/index.ts:164 and packages/examples/agent-spine-demo/src/index.ts:257.
  3. inject dependencies — gates a plugin's activation on service availability (e.g. static inject = [...] in packages/core/agent-loop/src/index.ts:297). This orders a plugin relative to services, globally and single-directionally, and forces a dependency edge. It cannot express the relative order of two listeners already on the same hook, nor opposite orders on different hooks.
  4. Order invariant assertions — detect a wrong order after the fact (packages/context/time-context/src/invariant.ts:66) but do not enforce one.
  5. Profile cordis.patch.yml row order — explicitly carries no load semantics ("activation is service-availability driven", packages/bundle/base/cordis.patch.yml:13), so it cannot sequence listeners at all.

HookOrdering/SerialHookOrdering replace all five with a single declarative primitive: name your before/after, register into the coordinator, and the order is stable regardless of load timing — with the constraint graph available as a JSON DAG when it goes wrong.

API

ctx.hooksOrdering — the HookOrdering service (waterfall)

Method Description
control(hook) Install the bracket on a waterfall hook. Call once per hook. Returns a disposer. Throws HookControlError if already controlled.
register(hook, phase, entry) Add a participant to 'front' or 'back'. Returns a disposer. Throws HookControlError if the hook is not controlled.
plan(hook, phase) Return the participant names in the order they would run — for tests and diagnostics.
dumpDag() Return the constraint DAG of every controlled hook as a JSON string.

Config: ctx.plugin(HookOrdering, { log?: string }).

ctx.serialHooksOrdering — the SerialHookOrdering service (serial)

Same surface as HookOrdering (control / register / plan / dumpDag, same config). The entry's run may return a value: a bail value (anything but null/false/undefined) short-circuits the serial dispatch and becomes its result.

HookEntry / SerialHookEntry

Field Meaning
name Unique within a (hook, phase). Referenced by other entries' before/after.
before? Names this entry must precede.
after? Names this entry must follow.
run(...payload) Called with the hook payload (waterfall: dispatched args without Cordis' trailing next). Awaited. Serial run may return a bail value.

topoSort(entries) (.../topo-sort) and buildDag(sections) (.../dag)

The zero-dependency stable topological sort, exported standalone. Ties keep input order; an unknown before/after target is a no-op; a cycle throws OrderingCycleError. buildDag renders the constraint graph as a plain object ({ sections: [{ hook, phase, nodes, edges }] }) for JSON.stringify — it never sorts and never throws on a cycle.

Semantics and limits

  • Waterfall and serial only. The waterfall bracket needs next(); serial uses two coordinators with bail semantics. emit/parallel/bail hooks have no ordered chain to coordinate.
  • One coordinator per hook. A second prepend would reintroduce the race, so control rejects a double-take.
  • It orders what it owns. The coordinator controls its front/back registries and their internal order, and places them relative to the native chain. It does not reorder foreign listeners among themselves.
  • Waterfall back is exact; serial back is best-effort. Waterfall back runs after the whole native chain via next(). Serial has no next(), so its back coordinator is appended at control() time and runs after the listeners present then — a native listener added after control() will run after it, and any bail (native or front) skips it entirely.
  • Unknown reference = no-op. A cross-vendor after: ['maybe-absent'] imposes no constraint when that peer is not loaded — cross-vendor plugins cannot assume each other's presence.
  • Cycles fail loud at dispatch. Conflicting constraints throw OrderingCycleError naming the blocked entries (and dumpDag() still renders the cycle for inspection).

Development

pnpm install
pnpm test            # vitest, unit + real-cordis integration
pnpm test:coverage   # 100% per-file gate
pnpm typecheck
pnpm lint
pnpm build           # tsdown -> lib/ (ESM + d.ts)
pnpm demo            # the problem and the fix, waterfall + serial, side by side
pnpm demo:dsh        # real dispatch on real dsh hook names + DAG log file
pnpm playground:build   # -> playground/dist (deployed to GitHub Pages)

CI installs with pnpm against the public npm registry (see .npmrc).

License

MIT

安装

🧩 让 Agent 自动装(推荐)

装一次目录插件,之后本站所有插件都能让 DeepSeek Harness 自动找、自动装:

dsh plugin add dshbase-catalog

然后对 agent 说「帮我装 dsh-plugin-hooks-ordering」,它会在目录里找到并自动安装。文档:dshbase-catalog · 已验证场景包

该插件是 GitHub 源码(未发 npm)——直接从仓库装:

Web profile:

dsh plugin --profile web add github:xinyuehtx/dsh-plugin-hooks-ordering

Headless(CLI)profile:

dsh plugin --profile headless add github:xinyuehtx/dsh-plugin-hooks-ordering

实测报告

尚未 L3 验证——若已跑过,见下方失败备注。

状态:pending · 最近测试 2026-08-26
备注:验证: runtime-fail 浏览全部待验证失败 →
安全:尚未扫描——我们的每日静态扫描将很快覆盖它。

分享徽章

Developer 里更多

浏览全部 7795 个插件 →