Zum Hauptinhalt springen
G

dsh-goal-zcode-like

gjayun/dsh-goal-zcode-like

Installation

dsh plugin --profile web add github:gjayun/dsh-goal-zcode-like

README

dsh-goal-zcode-like

Long-horizon goal management for DeepSeek Harness, modelled on ZCode's goal mode.

Repository: https://github.com/GJayun/dsh-goal-zcode-like

Supplies the one piece the harness's built-in goal loop lacks: a separate, evidence-gated verification pass after every round. Automatic rounds continue only while verification reports the objective unmet, which turns "nudge the agent and repeat continue" into "set a goal, wait for the result".

Install

git clone https://github.com/GJayun/dsh-goal-zcode-like
dsh plugin --profile web add ./dsh-goal-zcode-like

Restart the DSH instance and refresh the browser afterwards.

The command forwards its arguments to pnpm inside the profile directory, and a package declaring dsh.bundle.patch joins the profile's bundle stack automatically. Relative path specs are anchored to the invoking directory, which is what the path above relies on. github:GJayun/dsh-goal-zcode-like works too, as does the registry name dsh-goal-zcode-like once published.

The composition row id is goal-zcode-like. The ledger directory and HTTP namespace keep the unscoped dsh-goal-zcode-like name, so neither depends on the package name.

Design stance

ctx.goals is the single authority for the objective, phase, revision, round count, and activation; @deepseek-ai/dsh-goal-round-driver owns continuation. This plugin adds only the overlay, so the built-in /goal, the get_goal / create_goal / update_goal tools, and the shipped goal card all keep working, and edit / revision semantics are untouched. No command is registered by default, so there is no three-way /goal contest.

The verification pass is inserted through activation, not a second loop:

a native round is admitted   ->  ctx.goals.disarm(agent)     the driver stops queueing ahead
the round ends unverified    ->  inject the <goal_verification> pass
the model records a verdict  ->  resume re-arms -> the driver opens the next round
                                 complete / block -> the goal ends

disarm and resume are public ctx.goals contract, so no private API is involved.

Usage

The goal is set with the built-in /goal: /goal <objective> starts round 1, /goal shows status, /goal pause|resume and /goal clear behave natively. From that point the plugin owns each round's ending, and automatic continuation stays paused until the model calls goal_verify. A richer status command can be registered by setting command to a name such as goalx, which adds /goalx status|verify.

ToolPurpose
goal_plan(items, iteration_title?)Record the work as concrete checklist items
goal_progress(item_id|title, status, notes?)Move one item through its lifecycle
goal_verify(achieved, evidence, next_step?, blocked?, blocked_reason?, checklist?, reasoning?)Close the round with a verdict
goal_status()Read the round ledger, grouped checklist, and per-round evidence

The per-round contract

Plan first with goal_plan (3-7 concrete, verifiable steps), mark an item in_progress before starting it and completed only once it is done, collect checkable artifacts as the work proceeds, and close the round with goal_verify — the only way a round ends.

Verification is judged on evidence, never on effort:

ConditionCode
achieved with no artifactEVIDENCE_REQUIRED (plan, todo, checklist, narration, summary are rejected by name)
achieved while a checklist item is openOPEN_ITEMS_REMAIN
Not achieved but no next_stepNEXT_STEP_REQUIRED
blocked with no blocked_reasonBLOCKED_REASON_REQUIRED

Every refusal leaves the loop unarmed, so a rejected verdict can never advance the goal.

A plan, a checklist item, and a verdict all belong to an admitted round, so the tools refuse outside one: goal_plan and goal_verify reject roundsStarted < 1, goal_verify returns ALREADY_VERIFIED for a round that already has a verdict, and goal_plan refuses a round that already has one — the verification gate treats that round as closed for good, so items added afterwards could never be collected. A verdict recorded for a round before it was admitted is dropped with a warning when that round is admitted.

ConditionOwned by
Verification proves achievementThis plugin → ctx.goals.complete
Pause, clear, or the composer's stop buttonThe native command and round driver
The round cap maxGoalRoundsThe native round driver
The same next_step repeating stallLimit timesThis plugin → ctx.goals.block
unverifiedRoundLimit consecutive rounds with no verdictThis plugin → ctx.goals.block
A deferral outliving deferralTimeoutMsThis plugin → ctx.goals.block, naming the handles

Host behaviour

Background work in flight: defer, then hand the decision to the model. A round can end because work was handed off and is still running, and opening the next round then produces an empty turn — which pressures the model toward declaring the goal done, the one action that stops the loop. The plugin withholds advancement and records the wait, and because it cannot see whether the round depends on that work, it asks instead of assuming: a <goal_parallel> turn names the handles and offers three choices — do work that does not depend on them, start nothing and be woken on settlement, or record a verdict now to resume immediately. The offer repeats only when the wait itself changed, at most maxParallelOffers times per deferral; offerParallelWork: false restores silent withholding.

Both pending-work signals are needed because they cover disjoint paths: ctx.jobs covers background commands and background one-shot subagents, while a continuable child registers no job and is visible only through ctx.subagents.listChildren's activity. Analysis: docs/deferred-rounds.zh.md (Chinese).

Aborting the session stops the goal; stopping the goal does not abort the session. The asymmetry is deliberate. dsh-goal-round-driver pauses a cancelled round only while activation === "armed", and a round is disarmed the moment it is admitted, so that guard never matches — the plugin therefore pauses the goal itself when a turn ends with reason aborted, committing the durable pause at quiescence. In the other direction, the plugin's panel pause leaves the running answer alone: disarm runs first and emits no durable goal/changed, so the driver's cancel branch never runs, and the durable pause() lands only once the agent is idle, where the driver's agent.status === "running" guard cannot match. The shipped card's own pause still cancels the answer, since that is the driver's listener at dsh-goal-round-driver:237.

Elapsed time excludes paused intervals. The goal domain models createdAt only, so the ledger records each pause interval and the snapshot subtracts the total; a goal first seen while paused anchors that stretch at first sight.

Tool approval withdraws continuation without interrupting. The listener is attached to approval/request (a waterfall) and only observes — it always delegates next(), so an approval outcome can never change. Answering "allow" continues uninterrupted; a round ending with the decision outstanding opens no new round; if nobody ever answers, the deferral timeout blocks the goal and names the waiting tool. Note that approval/asked does not exist in the event catalog — a subscription to it, wrapped in ctx.on?.(), never throws and never fires.

The goal card

@deepseek-ai/dsh-client-ui-goal registers conversation.input.dock with id: "goal", and that slot's replaceRisk is none. The plugin registers under the same id at a lower priority and takes over the cell, keeping the shipped surface (glyph, phase, truncated objective, inline editing, clear, inline failure) and adding a checklist grouped by iteration with per-round evidence, a progress bar with elapsed time and ETA, awaiting-verdict and advancement-withheld states, a pause that does not truncate the answer being written, and Chinese/English following the goal's language. An item stays under the round that first raised it.

Two deliberate deviations: resume is withheld while a verification pass is pending, because the shipped card's resume at active + disarmed would let the driver skip verification; and a completed goal stays visible with a dismiss control instead of vanishing.

Takeover is priority shadowing, not replacement. A list slot's duplicate check compares the pair (id, priority) and throws on a duplicate, while rendering takes the lowest priority per cell: the shipped card uses priority 0 and this plugin uses -1. The throw is swallowed by a try/catch, so a bad registration presents as "the shipped card is still there" — hence the client half logs a failed registration to the browser console. Shadowing affects that one cell only; @deepseek-ai/dsh-client-ui-goal keeps loading and its conversation.chat.node view keeps working.

Configuration

- id: goal-zcode-like
  name: 'dsh-goal-zcode-like'
  config:
    command: ''                # empty = register nothing, coexisting with the built-in /goal
    stallLimit: 3              # stop after the same next_step repeats this often
    unverifiedRoundLimit: 2    # stop after this many rounds with no verdict
    autoVerify: true           # queue a verification pass when a round ends
    announceProtocol: true     # brief the model on the round contract at goal creation
    deferWhileBusy: true       # withhold the next round while handed-off work is in flight
    offerParallelWork: true    # hand the model that decision instead of assuming it waits
    offerOnApproval: true      # also offer the turn while waiting on a human decision
    maxParallelOffers: 3       # stop offering the turn after this many per deferral
    deferralTimeoutMs: 600000  # pause the goal when a deferral outlives this bound
    storagePath: ''            # override the ledger file location

offerOnApproval separates work from human decisions: a pending approval blocks the very turn that would do anything else, so it is treated as routable work only when enabled.

Architecture

lib/overlay.js   Per-goal-id ledger: iteration grouping, verdicts, guards, deferrals, pause
                 accounting, atomic persistence (no Cordis)
lib/verify.js    Pure policy: evidence rules, grouping projection, stall signature, ETA
lib/pending.js   Outstanding-work probe and the parallel-offer decision
lib/prompt.js    Protocol, verification pass, parallel offer, closing instruction, report (zh + en)
lib/resolve.js   Session and Agent resolution across payload shapes
lib/index.js     Cordis host: goals/agents deps, the disarm/resume insertion, the deferral
                 gate, tools, HTTP API
lib/client.js    Cordis client: takes over the conversation.input.dock "goal" cell

The first five modules depend on neither Cordis nor any service and are fully unit testable. The host half reaches the harness only through ctx.goals, ctx.agents, ctx.jobs, ctx.subagents, ctx.logger, and the registered tools and routes.

The ledger lives at $DSH_HOME/storages/dsh-goal-zcode-like/overlay.json (~/.dsh when DSH_HOME is unset), keyed by goal id and written atomically. The goal itself is persisted by ctx.goals through the session log.

MethodPathPurpose
GET/dsh-goal-zcode-like/state?sessionId=Merged snapshot
GET/dsh-goal-zcode-like/events?sessionId=SSE stream of live snapshots
POST/dsh-goal-zcode-like/action{sessionId, action, objective?}, action ∈ start pause resume edit verify clear

Development

npm test                  # 57 unit tests (verify, overlay, resolve, pending, client)
node scripts/smoke.mjs    # 21 end-to-end scenarios: stubbed ctx.goals/jobs/subagents + real apply(ctx)

The smoke test mounts the plugin against a ctx.goals stub that records every call and drives it the way the harness would: admitting a round disarms the goal; an unverified round gets a verification pass; missing evidence or open items are refused without re-arming; an unachieved verdict re-arms via resume and titles the next round; an achieved one calls complete with its evidence; consecutive unverified rounds call block; work in flight withholds the round and offers the parallel decision exactly once; settlement runs the original round's verification pass without advancing the round number; an aborted turn pauses the goal; an outstanding approval is delegated unchanged; teardown runs every disposer and flushes the ledger. It does not run the real round driver.

Boundaries

  • The interaction with the real round driver is source-inferred, not observed. The smoke test proves this plugin's side of the contract; that disarm stops the driver queueing rests on drive() returning early when activation !== "armed", plus the ordering of agent/inbox/claimed, agent/pre-step, and turn/end. The same applies to the claim that settlement wakes the agent. See docs/deferred-rounds.zh.md §10.
  • The protocol does not go through the system prompt. systemPrompt.section's text callback receives an AssembleContext { scope?, signal? } with no session identity, so the contract is delivered through one <goal_protocol> message at goal creation and the per-round verification prompt.
  • The takeover mechanism is confirmed from source but has not been observed in a browser. If the shipped card is still what appears, the shadowing did not take effect and the client half logs the failed registration. Taking over the cell also means upstream improvements to it stop arriving.
  • Client visuals are verified structurally, not visually. The tests render the card through a hook harness and assert sections, row budget, measured height, and equal gutters; no browser is available in the development environment.
  • /goal pause still cancels the running answer, because dsh-command-goal calls ctx.goals.pause server-side. Fixing it would require this plugin to own /goal, reintroducing the command conflict that was removed.
  • Not implemented: auto-pause on approval requests, a consecutive-tool-failure circuit breaker, notifications, quick-launch templates, a settings card, and Markdown report export.

License

MIT

Ähnliche Plugins