@starsinc1708/dsh-tool-council
A map-reduce council of subagents for the DeepSeek Harness: one task fans out to several fresh children through different lenses, verifiers re-check each finding from the source, and a quorum turns their votes into a verdict table.
The plugin is a Consumer over the workflow and subagent seams. Its script is deployment-owned and build-time constant: the model supplies the task text and, optionally, a preset name — it cannot change the topology, the schemas, the quorum, or the validation. Concurrency limiting, cancellation, worker termination, and the workflow-run conversation node come from ctx.workflowEngine.
Install
One command, into the profile dsh web boots:
dsh plugin --profile web add github:starsinc1708/dsh-tool-council
Then start the harness and pick the mode:
dsh web
Map-Reduce mode appears in the composer's mode menu beside Standard, PTC, Minimal and Creator. Selecting it composes the council onto the standard agent plane; every other mode is left exactly as it was.
No build step and no pnpm allowBuilds allowance is needed: this repository
commits its lib/ output, so the install resolves to prebuilt artifacts. Pin a
commit if you would rather a later push could not change what you run:
dsh plugin --profile web add github:starsinc1708/dsh-tool-council#<sha>
Requirements
- DeepSeek Harness
0.1.1-rc.2(dsh --version), withpnpmonPATH. - The
webprofile, which composes@deepseek-ai/dsh-baseand@deepseek-ai/dsh-web-app. Everything the council needs — the workflow engine, the subagent registry with thespawnprovider, the settings provider, and theworkflow-runconversation node — is already in those two bundles. Nothing else to install.
Verify it landed
dsh --profile web --dump-config | grep -A3 dsh-tool-council
A # == @starsinc1708/dsh-tool-council layer with a tool-council-host row
means the bundle composed. After the first dsh web start, the published preset
is on disk:
ls "$DSH_HOME/.agent-presets/map-reduce" # agent.cordis.yml preset.yml
$DSH_HOME defaults to ~/.dsh. Publication happens at plugin load, a second
or two into boot, and preset discovery is unmemoized — the mode shows up without
a restart.
Other install sources
dsh plugin --profile web add ./dsh-tool-council # a local checkout
dsh plugin --profile web add ./starsinc1708-dsh-tool-council-0.1.1-rc.2.tgz # pnpm pack output
Both skip the git fetch and need no build allowance either. Use a local checkout
while developing: pnpm build then restart dsh web.
Update and remove
dsh plugin --profile web update @starsinc1708/dsh-tool-council
dsh plugin --profile web remove @starsinc1708/dsh-tool-council
remove drops the dependency and the bundle layer, so the tool and the settings
card disappear on the next start. It does not delete the published preset —
$DSH_HOME/.agent-presets/map-reduce is yours once written, and the roster would
list it as broken with the plugin gone. Delete that directory too:
rm -rf "$DSH_HOME/.agent-presets/map-reduce"
What installing this does to your machine
Two things worth stating plainly, because both are outside the agent sandbox:
- It writes one directory into
$DSH_HOME/.agent-presets. A preset is a composition, so the harness treats authoring one as carrying the same trust as shell access. The directory is regenerated whenever the sourcestandardpreset or this plugin's rows change, and hand edits to it are lost — copy it under a new id to diverge, and setinstallPreset: falseto stop the plugin writing at all. - A run starts fresh subagents that can read and run commands in your
workspace. That is the point — a verifier re-reads the file it is voting on
— but it means a council run costs real tokens and real tool calls: a
bug-huntis eight children.
Config
# profiles/<name>/cordis.patch.yml (or the bundle's own cordis.patch.yml)
- insert:
- id: tool-council-host
name: '@starsinc1708/dsh-tool-council'
config:
installPreset: true
presetId: map-reduce
presetName: 'Map-Reduce mode'
councilPolicy:
subagentProvider: spawn
toolName: council
maxAgentsPerLayer: 100
maxLayers: 6
maxFindings: 200
maxFindingsPerMember: 50
maxFindingChars: 2000
maxReportChars: 32768
maxRunMs: 0 # 0 disables the wall-clock budget
retryFailedMembers: true
mergeSameLocation: true
maxMergeCandidates: 60
councilEveryRequest: true # false = offer the council, do not mandate it
defaultPreset: bug-hunt
# presets: [] # replaces the four shipped topologies wholesale
councilPolicy is the tool's own configuration, owned by the always-composed host row so the settings card can mirror the deployment's real topology and the published preset can mount the tool with the same policy. Omit it for the four shipped topologies (bug-hunt, research, feature-design, refactor) and the default ceilings. There is no per-preset merge — declaring presets replaces them wholesale, because a partially overridden role prompt is a topology nobody reviewed.
Structural rules the schema cannot express are enforced at load and fail the deployment rather than the call: every preset ends in a reduce layer with exactly one role instance, at most one verify layer is declared and no map layer follows it, a quorum appears on a verify layer and nowhere else, a threshold quorum is at most its layer's width, and preset, layer, and role ids are unique within a preset. The last two are not pedantry — a map layer after the verify layer re-clusters and renumbers the findings the ballots were cast against, and a role id reused on a second layer collapses two members into one instance id. Both would otherwise fail at the end of a run, after every child had been paid for.
subagentProvider must be registered, must advertise outputSchema, and must not inherit parent context. A member seeded with the parent's transcript would inherit the parent's framing of the problem, which is what the layer exists to break.
Budget and failure ceilings
maxRunMs is a wall-clock budget for one run; 0 (the default) leaves it off. It is enforced twice, on purpose. The script checks it at each layer boundary and skips the remaining examine/verify layers while still running the trailing reduce layer, so an over-budget run returns the findings it did gather, flagged deadline, instead of nothing. The host keeps a hard run.cancel() backstop at maxRunMs + 60s for the case the script's own check cannot help — a single layer that never settles. Children already in flight are never killed mid-layer.
retryFailedMembers (default on) re-issues one agent() call whose child died. A dead child resolves its call to null rather than throwing, so without the retry one transport failure silently removes a whole lens and nothing in the report says so. maxTotalAgents is sized to cover the retries and the single merge child, because a tripped AGENT_CAP kills the run rather than degrading it.
A reduce child that returns nothing is reported as a missing report, not as an empty one: the tool result says so above the table and the durable record carries reportMissing.
Layers
A layer is map, verify, or reduce. Its width is the sum of its roles' count, and each instance runs as one agent() call under the engine's concurrency limit.
A role differs from its neighbours by its prompt and its optional model/provider, and by nothing else: the workflow agent() hook accepts neither a persona nor a tool filter. Members do reach the workspace — a spawn child joins the parent's preset — which is what makes a verifier's vote worth counting: it re-reads the cited location rather than reasoning from the finding text.
Findings and quorum
Map children return findings through a structured output schema, so nothing parses prose. Each member's list is capped at maxFindingsPerMember as it is read, so one talkative member cannot fill the slice and crowd the quieter ones out — and cannot grow the accumulated list past instances × maxFindingsPerMember either. Findings then cluster on normalizeLocation(location) + '|' + fingerprint(title); the first-seen member survives and later members contribute a reporter and a title variant.
Clustering is lexical, so two members describing one defect in unrelated words at the same location still arrive as two findings. When mergeSameLocation is on (the default), a merge child receives exactly those groups — clusters sharing a location but not a fingerprint — and returns the id sets that are one defect; the earliest cluster absorbs the others' reporters and variants, and the ids are renumbered. Merge groups chain: told f1 ≡ f2 and f2 ≡ f3, the fold produces one finding carrying all three reporters, in whichever order the groups arrive. A merge child that dies leaves every cluster standing.
maxMergeCandidates is shared across the ambiguous locations rather than handed out first-come, so one hot file cannot consume the budget and leave every other location unmerged; whatever still does not fit is named in the run log rather than dropped quietly. The whole step runs once per run, at the last map layer — clustering per layer would rebuild the list from scratch and throw the previous layer's merge decisions away with it, since the ids they were expressed in no longer exist after renumbering. Both reduce modes run this pipeline: vote renders the verdict table as the answer, synthesis asks the reduce role for prose and hands it the same table as evidence.
Each verifier receives the whole deduplicated list, votes confirmed, rejected, not-a-bug, or uncertain per finding, and never sees another verifier's ballot. uncertain never confirms — it only denies unanimity. When a rule does not confirm, the modal negative vote decides between not-a-bug (the fact holds but the behaviour is correct) and rejected (the claim is wrong); the distinction changes the follow-up action, so it survives the tally.
Abstentions do not count. The quorum's denominator is the number of verifiers who voted on that finding, not the number of ballots the layer collected: a verifier that returned no verdict for a row abstained on it, and its silence would otherwise make one confirmation plus one abstention read as a quorum of two. A · in the table is that abstention, and the rendered legend says so.
insufficient is the unresolved arm, not a negative one — the rule was not met and nobody argued against the finding. Two situations reach it: fewer than two verifiers voted on the row, or the ones who did could not clear the bar the rule sets (a threshold of three that only two verifiers reached, unanimity denied by an uncertain). Neither is rejected, because nobody said the claim was wrong. A preset with no verify layer at all reports unverified instead: nobody was ever asked.
./tally.ts is the host's authoritative copy of that arithmetic. The script runs its own copy because the verify layer needs deduplicated findings during the run and cannot import this package. That duplication is guarded on both sides of the boundary. At runtime the host recomputes the quorum from the raw ballots and refuses a run whose script tally disagrees, naming the first row and field that differ; it also refuses clusters that break the invariants clustering guarantees — contiguous ids in report order, one cluster per location+fingerprint key, duplicate-free reporter and variant lists. At build time tests/parity.spec.ts runs both copies of all five duplicated functions over thousands of seeded inputs and compares every output, so drift fails the commit rather than the run. The host does not recompute the clustering itself at runtime: that would mean carrying the whole raw finding list back across the boundary and roughly doubling the payload, which is not worth it once the parity gate makes silent drift a build failure.
Settings
The host row serves the council settings namespace. The section carries defaultPreset, a sparse overrides map keyed by preset, and three read-only mirrors the host writes as the section's base layer: topology, maxAgentsPerLayer, and agentPresetId. Those mirrors are what let the browser card draw the deployment's real layers without a Remote namespace, bound its width input against the real ceiling, and gate the Council tab on the preset id this deployment actually published rather than the shipped map-reduce.
The card stages edits locally and lands them in one save, marks itself unsaved changes while anything is staged, and holds a beforeunload guard so closing the tab asks first.
The overlay is legible without opening every tab. Each preset tab carries an override-count badge (Bug hunt ·2, roles plus quorums), and one line above the layers says how much of the composition the overlay is changing, with a Reset all beside it that clears the whole staged map. Without the badges, the only way to find out what you overrode is to open all four tabs — which is how a role ends up badged overridden at its default value with nobody able to find it. The counts are computed in CouncilCardController, not in the card, so the badges and the summary can never disagree and both are unit-tested; Reset all is staged like every other edit, so it marks the card dirty and Discard puts the overlay back. An overlay may change a role's width, model, or provider and a layer's quorum; it may not change a topology. An overlay the host would refuse is refused twice: the card names the offending preset and layer and disables Save before the write — for a width past maxAgentsPerLayer and for a threshold its own layer cannot reach — and the host refuses the write itself for any client that does not. The three read-only mirrors are refused as user writes too, so a raw API call cannot shadow what the card believes without changing what the tool runs.
The validate hook compares against values captured at registration, never recomputed — see assertMirrorsUnchanged, whose mirrors are parameters for exactly that reason. validate runs later than apply (inside ctx.inject(['settings'], …)), so anything it reads from the live context can legitimately have changed since the base layer was written. When it throws, register throws, the inject callback's rejection is swallowed, and the namespace silently never registers — which makes the settings card vanish from Settings → Plugins with no error in any log. That is a failure mode worth knowing about before adding a check here. Clearing a model or provider field removes the override rather than storing an empty string, so the role stops being marked as overridden. The whole overlay can be copied, downloaded, or re-imported as JSON from the card; an import that would discard staged work asks first. Model and provider are free-text fields with a datalist of the routes this deployment already names — a closed list would hide every valid custom id. The suggestions come from the topology and the staged overlay only: the subagent registry is not published on the host plane at composition time, so a mirror of it would be empty by construction. The tool row reads the section fresh on every call, so an edit lands on the next run without a recomposition.
The Council tab
The Council conversation view renders each run as a graph of its layers and members — live status, per-member and per-layer tokens, per-layer duration, and a one-line role explanation — followed by the run's verdict table and written report, with Markdown and JSON export. Each run's header carries its task snippet, start time, and an over-budget or failed chip, so a collapsed list of runs is still readable.
A run in flight shows a ticking clock, its running token total and cost estimate in the header, and per-layer member counts (1 running · 3 done) beside the layer's tokens. Two things about those numbers are worth stating, because neither is derivable from the live run and both are reported honestly rather than guessed:
- The counts are of what has STARTED, never a
2/3fraction. Theworkflow-runnode publishes a member only once it launches, and the artifact that carries each layer's real width lands only when the run settles. The declared width shown asof N declaredcomes from thecouncilsettings section — the deployment'stopologymirror plus the savedoverrides, which is the same pair the tool resolves on every call — joined to the run by the preset id in the run's name (council:<presetId>). It is a live read, so an overlay edited mid-run would make it disagree with what that run actually launched; that is why it sits beside the counts instead of under them as a denominator. - The clock says which clock it is. Neither
RunDatanor the chat node carries a start time —ConversationViewNodehas notimefield andanchorSeqis a sequence number. What the same snapshot does carry is the still-runningtool/callhead, whosetimeis the exact millisecond the council call was logged; the view joins it by the call's own turn and step and refuses the join when that step holds more than one call in flight, because nothing there can tell which call owns which run. When the join is refused the header falls back to when this tab first saw the run and sayswatched hererather thanelapsed— a clock that resets on reload has to admit it.
The timer exists only while the run's status is running: the live header is a separate component, so settling unmounts it, which is what clears the interval and drops the token subscriptions it opened. Only the newest run is expanded; older ones collapse, which is also what stops a finished run from holding live token subscriptions open for the rest of the session. Long verdict tables draw their first 50 rows with the rest one click away. Both exports are offered as a clipboard copy and as a file download, because clipboard access is permissioned and silently unavailable in some webviews.
Each row carries the reporting member's severity as a coloured badge (blocker, high, medium, low), and three chips above the table filter it: confirmed, unresolved, and all. unresolved is both unresolved arms together — INSUFFICIENT and NOT VERIFIED — because they differ in why nobody settled the row, not in what they leave the reader to do; splitting them would leave one chip permanently at zero on every preset. Every chip carries the count it would show, so an empty table never reads as an empty run. The chip is applied before the 50-row window (windowRows owns that order, and a test pins it) — windowing first would drop a blocker confirmed at row 60 from a confirmed chip claiming to show it — and "show all" counts the filtered total. Rows keep their number from the whole run, so #7 is the same finding under every chip and in the export.
A finding is something you act on, so each location is a chip that copies itself, and an adjacent arrow opens the file. Opening goes through the one seam the client runtime exposes for this — ctx.workspaces.openPath(path) on the injected workspaces service, which hands the path to the host operating system's default application. It is the same call the harness's own chat makes for a file mention. Locations are workspace-relative, so the path is joined against the session's cwd before the call; a session with no workspace root sends the path unchanged rather than guessing a root and opening the wrong file. Both the chip and the exports share one clipboard path, so a permissioned clipboard that refuses says copyFailed instead of failing silently.
Beside the two exports there is a third: the confirmed findings as a Markdown checklist (- [ ] {title} — {location}, with the fix as a sub-item when the member proposed one), offered as a copy and as a download. Confirmed only, because an unresolved row is not yet work. toChecklist is a pure exported function with its own tests, including that a newline inside a member-authored title is collapsed — otherwise a title containing \n- [ ] already fixed would forge a checklist entry nobody reported.
Severity is a self-report: the member that filed the finding chose it, and a blocker nobody confirmed is still only a claim. The durable record stores it as a plain string, so a level written by a differently-configured build renders its own text in the neutral badge instead of resolving a locale key that does not exist. The Markdown export carries the severity column too; the parent model's table does not — renderTable in tool.ts is a token-budget decision, not a UI one, and it is deliberately left alone.
The report renders as structure, not as a <pre>. The synthesizer is prompted for numbered sections and lists, so preformatted monospace throws away the only shape its output has — but both obvious fixes are closed. Importing the harness's Markdown renderer is a cross-plugin value import the bundle-purity gate forbids (and not a declared dependency here), and bundling a Markdown parser adds weight plus an HTML-injection surface on model-authored text, which is precisely the input you do not want near innerHTML.
So report.ts does the smallest honest thing: a pure function from report text to a block list — headings (#…###, deeper levels clamped), ordered and unordered lists, fenced code, inline code, and everything else a paragraph with its whitespace preserved — and the view renders those blocks as React elements. No HTML is constructed and dangerouslySetInnerHTML never appears, so a <script> in a report is nine characters of text and the XSS surface does not exist rather than being defended against. It is deliberately not Markdown: emphasis, links, tables, block quotes, and nested list depth are not modelled and survive as the literal characters the synthesizer wrote. tests/report.spec.ts drives the parser directly, including unclosed fences (they close at end of text instead of swallowing the report), a lone line of backticks, an unpaired inline backtick, and HTML tags in every arm. toMarkdown still exports the raw report unchanged — the export is the record, not the rendering.
That last part is durable, and it travels through the one channel a plugin actually has. The run's artifact — topology, narration, per-layer timing, verdict rows and the report — is the tool's presentationMeta, which the harness persists on the tool/result event it writes anyway; the tab reads it back from there. A finished run therefore reopens with its table intact from a fresh client session.
The plugin writes no private event types, and must not. The session reader validates every record against the harness's KNOWN_SESSION_EVENT_TYPES and refuses to interpret a log containing an unknown type that is not marked ignorable — and Session.append() gives an out-of-repo plugin no way to set that marker (dsh-session says so: "a registration surface for them is deferred until such a consumer exists"). Versions up to 0.1.1-rc.2 appended a tool-council/* family and so made their own session logs unreadable on the next start:
SessionFormatUnsupportedError: session "…" contains event type "tool-council/run-start"
(seq …) unknown to this harness and not marked ignorable; refusing to interpret the log
If you hit that, repair the affected logs in place — it only adds the ignorable marker to those records, and keeps the original as *.bak:
node scripts/repair-council-sessions.mjs # scan and report
node scripts/repair-council-sessions.mjs --write # repair
Only tool-workflow/* records are appended now, and tests/recorder.spec.ts checks the appended vocabulary against the harness's own catalogue rather than a hand-written allowlist.
Model Experience
Council members
Nothing directly. Each member receives the preset's framing, its own role prompt, and the verbatim task text; no member sees the parent conversation or a sibling's output. One fresh child context per role instance per layer, bounded by maxAgentsPerLayer and the engine's concurrency limit — a bug-hunt run is eight children. None of them joins the parent history.
Parent tool result
A one-line count — N of M examining members answered; K reported … distinct findings; … verifiers voted, confirming … — then the Markdown verdict table with its legend, then the synthesizer's report, all bounded by maxReportChars. Answering and reporting are counted separately because the map prompt calls an empty list a valid answer, and folding them would make a clean run where nobody found anything read as four dead children. A run that hit its budget or lost its synthesizer says so above the table rather than presenting a partial council as a complete one; when there is no verdict table and no report, the findings themselves are listed rather than silently dropped. Individual member transcripts and per-verifier reasons stay out of the parent context. Wording attributes conclusions to the members — "two of three verifiers confirmed" — because verifiers are agents re-reading the same repository, not an independent oracle.
Development
pnpm install
pnpm build # tsc -> lib/types, tsdown -> lib/*.js (host halves + client bundle)
pnpm test # vitest: parity gate, tally arithmetic, policy rules, script body,
# host rendering, recorder, settings overlay + card, client fold
pnpm build emits both halves. The host entries are ordinary ESM; the browser
entry is not — lib/client.js must be the loader's lazy-CJS factory artifact
(window.__ModuleLoader__.load({ id, factory })), which is what
tsdown.config.ts reproduces. The harness's own
clientBundle preset is not published, so an out-of-tree package owns that
format itself. Two rules the config encodes:
- The shared module table is small —
react,react/jsx-runtime,react-dom,react-dom/client,@deepseek-ai/cordis,dsh-client-ui-slots,dsh-client-ui-primitives, plus the preloadeddsh-client-runtime/client. Everything else must inline, or the factory throws on arequirethe table cannot answer. - Cross-plugin value imports are forbidden. Collaborate through cordis services and keep the rest type-only.
- The same rule catches the package's OWN subpath.
deps: { neverBundle }externalizes every bare specifier, so a value import of@starsinc1708/dsh-tool-council/typesfromsrc/clientcompiles, bundles, and then fails at load withrequire(…) missed the module table. Import values from../types.tsrelatively so they inline; the specifier is fine forimport type, which is erased before the bundle exists.tests/bundle.spec.tsenforces both directions.
The @deepseek-ai/dsh-* packages are devDependencies on purpose. At runtime
they resolve to the harness installation through the profile's Node parent-walk,
so declaring them as runtime dependencies would install a second, older copy into
the profile and shadow the one the harness is actually running.
To iterate against a live harness, install the checkout and rebuild in place:
dsh plugin --profile web add .
pnpm build && dsh web
Known Limitations and Deferred Work
- A role cannot restrict its own tools or persona — the workflow
agent()hook exposes neither, so per-role isolation would mean leaving the workflow engine forctx.subagents.start()and re-owning the concurrency gate, cancellation, and progress UI. - The settings card and the tool sit on different planes — the card is served by the bare-name host row (host plane) and the tool runs inside the published preset (agent plane). The link between them is the
councilsettings namespace: the host owns it, the tool reads it at call time. A deployment that mounts the tool row by hand without the host row gets the tool but no settings section. - Votes measure agreement among correlated agents — distinct lenses and per-role model and provider routes reduce the correlation between members but do not remove it. A quorum is a count of self-reports, not certification.
- The merge step is a judgement, not a proof — the deterministic key is lexical, and the merge child that resolves same-location ambiguity is an LLM with its own failure modes. It is scoped to the one decision the key cannot make, its candidates are capped, and a wrong merge is the way a finding can silently disappear. Set
mergeSameLocation: falseto keep the lexical behaviour. - The run budget is checked between layers —
maxRunMscannot stop a layer that is already running; it stops the next one and falls back on a hostcancel()after a grace period. It is a ceiling on how long a run keeps spending, not a deadline it will hit precisely. - The parent's table is capped at 100 rows — a deployment that raises
maxFindingsabove that gets a counted truncation notice and the full table in the Council tab, rather than a table the report ceiling cuts off mid-row. - The Council tab is read-only — it shows and exports a run; it cannot cancel or re-run one. A cancel button would have to reach for
session.cancel, which stops the parent's whole turn rather than this run, so a documented instruction is honestly better than a button that does something else. Precise cancel and one-click re-run both need a Host RPC namespace, which is the Client composition owner's call, not this package's. Cancel the parent step instead: the tool cancels its run with it. - The provider suggestions are a boot snapshot —
availableProvidersis read once at composition and the settings seam has no way to refresh a base layer, so a provider registered later appears only after a restart. That is exactly why both route fields are adatalistand never a<select>: the list is a hint, and any valid id can still be typed. - The cost figure is your arithmetic, not a bill —
costPerMillionTokensis off by default and blended by construction. The meter reports no price and the view cannot know which route each member actually ran on, so a per-route figure is impossible here; what is shown is your rate times real token counts, labelled as an estimate. - A persisted artifact is validated, not trusted —
isArtifactchecks every field the tab dereferences, rows included, not justkind/version/runId.versionalone cannot police the input: a build that shipped a bug wrote version 1 too, and artifacts are replayed from logs other builds wrote. A record that fails the check is ignored, so the run degrades to its live member graph instead of throwing inside the render and blanking the whole tab. - A location opens its file, but never at its line —
ctx.workspaces.openPath(path)is the whole seam the client runtime exposes: it hands a path to the operating system's default application. There is no reveal-at-line or open-in-editor API anywhere on that face (IWorkspacesis workspace registration, directory browsing, session connection, and this one open), sorank.py:521opensrank.pyand the line survives only in what the chip copies. Jumping to the line needs an upstream seam — an editor/reveal capability on the Host — and no amount of client work substitutes for it. What the file opens in is also the operating system's choice, not this plugin's. - The Council tab mirrors two renderer contracts by hand —
workflow-run's member/phase payload shapes and its status union are restated incouncil-view.tsxbecause the package exports them only from a subpath itsexportsmap does not publish. Fixing that needs an upstream export; until then a change to those shapes shows up as a rendering bug, not a type error. - A verify layer always spends every ballot — all verifiers of a layer start in one
parallel(), so there is nothing to stop early once a majority has decided. Spending fewer would mean not starting them, which is a width change, not a runtime one. - Topology is an acyclic chain — there is no
verify → fix → re-verifycycle, at most one verify layer, and exactly one trailing reduce role. Iterative execution belongs to@deepseek-ai/dsh-tool-ralph. - Nothing crosses layers except findings and votes — member transcripts are not carried forward; the workspace is the shared memory.
- A long-lived peer team is a different shape —
@deepseek-ai/dsh-experimental-agent-teamowns the roster, mailbox, and shared task DAG; this plugin is a bounded fan-out that settles in one call.
No comments yet. Be the first to write one.