# @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: ```sh dsh plugin --profile web add github:starsinc1708/dsh-tool-council ``` Then start the harness and pick the mode: ```sh 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: ```sh dsh plugin --profile web add github:starsinc1708/dsh-tool-council# ``` ### Requirements - DeepSeek Harness `0.1.1-rc.2` (`dsh --version`), with `pnpm` on `PATH`. - The `web` profile, which composes `@deepseek-ai/dsh-base` and `@deepseek-ai/dsh-web-app`. Everything the council needs — the workflow engine, the subagent registry with the `spawn` provider, the settings provider, and the `workflow-run` conversation node — is already in those two bundles. Nothing else to install. ### Verify it landed ```sh 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: ```sh 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 ```sh 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 ```sh 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: ```sh 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: 1. **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 source `standard` preset or this plugin's rows change, and hand edits to it are lost — copy it under a new id to diverge, and set `installPreset: false` to stop the plugin writing at all. 2. **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-hunt` is eight children. ## Config ```yaml # profiles//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/3` fraction.** The `workflow-run` node 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 as `of N declared` comes from the `council` settings section — the deployment's `topology` mirror plus the saved `overrides`, 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:`). 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 `RunData` nor the chat node carries a start time — `ConversationViewNode` has no `time` field and `anchorSeq` is a sequence number. What the same snapshot does carry is the still-running `tool/call` head, whose `time` is 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 says `watched here` rather than `elapsed` — 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 `
`**. 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`](src/client/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 `