@maci0/dsh-legion 0.0.0-stage → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marcel W. Wysocki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,155 @@
1
- # Temporary Holding Version
1
+ # dsh-legion
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Recursive task decomposition for DeepSeek Harness: spydr's workflow on top of
4
+ the harness's native Agent Teams. One root task splits into a tree, leaves run
5
+ as teammates against a shared task board, results consolidate upward, and the
6
+ human gates the root.
7
+
8
+ ## Install
9
+
10
+ > **Install it as a bundle.** `dsh plugin add …` mounts the row from the
11
+ > package's own patch layer, which is what the settings editor can write to. A
12
+ > row added with `--patch` is an overlay: it disappears at the next start, and
13
+ > the Plugins card cannot save into it (the editor refuses a write an overlay
14
+ > would win).
15
+
16
+ ```sh
17
+ dsh plugin --profile web add github:maci0/dsh-legion#v0.8.2
18
+ ```
19
+
20
+ Pin a release tag: a bare `github:` spec floats on `main`. To upgrade, run the
21
+ same command with the newer tag, then restart `dsh web` (bundle layers compose
22
+ at boot).
23
+
24
+ The bundled `cordis.patch.yml` inserts the `legion` row automatically.
25
+
26
+ Requires Agent Teams (`ctx.agentTeams`). When it is not mounted, every verb but
27
+ `config` answers an error naming the bundle to enable and queues nothing, and
28
+ the Legion view says the same. Agent Teams ships as an experimental bundle; enable it in the same
29
+ profile:
30
+
31
+ ```bash
32
+ dsh plugin --profile <name> add @deepseek-ai/dsh-experimental-agent-team-profile
33
+ ```
34
+
35
+ That bundle mounts `agent-team`, `tool-agent-team`, and the Agent Teams Web UI,
36
+ and its own patch disables `tool-subagent`, `tool-subagent-fork`,
37
+ `tool-subagent-list-agents`, and `tool-subagent-control`: the Team tools replace
38
+ direct delegation. Its defaults are `maxMembers: 8` and `maxTasks: 256`. Restart
39
+ the harness after the install.
40
+
41
+ ## Commands
42
+
43
+ ```
44
+ /legion refactor the auth layer # start a run (attachments allowed)
45
+ /legion start status page redo # start a task whose title begins with a verb
46
+ /legion status # roster + task board
47
+ /legion config # effective settings
48
+ /legion stop # interrupt every teammate, halt the lead
49
+ /legion approve # accept the root deliverable
50
+ /legion approve ship it # accept, with a note
51
+ /legion reject the API contract # send it back with a reason
52
+ ```
53
+
54
+ A verb is matched **whole**: `/legion status page redesign` starts a run whose
55
+ title begins with "status". To start a task that *is* one of the verb words,
56
+ prefix it: `/legion start stop`.
57
+
58
+ ## Configure
59
+
60
+ Two layers, same namespace (`legion`), same precedence as every DSH settings
61
+ section: the patch row is the base, the settings document overrides it.
62
+
63
+ | Field | Default | Meaning |
64
+ |---|---|---|
65
+ | `minSubtasks` | 2 | Fewer proposals than this ⇒ the task is a leaf |
66
+ | `maxSubtasks` | 4 | Extra proposals are truncated to this |
67
+ | `maxDepth` | 0 | Hard cap on decomposition levels; `0` = no cap |
68
+ | `workersPerTask` | 1 | Independent candidates per leaf |
69
+ | `mergeStrategy` | `best` | `best` \| `reconcile` |
70
+ | `maxReviewRetries` | 2 | Rework attempts before terminal failure |
71
+ | `requireHumanApproval` | `true` | Root waits for `/legion approve` \| `reject` |
72
+ | `maxTasksPerRun` | 0 | Task cap per run; `0` = unlimited |
73
+
74
+ Edit either way:
75
+
76
+ - **Config menu**: Plugins → the `legion` row's **Configure** control: every field with its bounds,
77
+ merge-strategy and approval toggles, overridden-field markers and a one-click
78
+ Reset, read-only state when the deployment does not persist settings. Its copy ships in
79
+ English and Chinese through the locale service.
80
+ - **Patch row**: a `- id: legion` row in your profile's `cordis.patch.yml`
81
+ (or the `config:` block in this package's `cordis.patch.yml`).
82
+
83
+ Changes validate immediately and apply to the next `/legion` run; `/legion
84
+ config` always prints what the next run will actually use.
85
+
86
+ ## The Legion view
87
+
88
+ A **Legion** tab sits beside Chat and Trajectory in the session. It draws the
89
+ run's task board as a hierarchy: one round-rect node card per task on a border
90
+ colored by status (completed, in progress, pending), joined by tree rails, each
91
+ carrying the task id, subject, owner, readiness, extra blockers, and write-scope
92
+ overlaps. Branches collapse from their own twisty, a legend names the statuses,
93
+ and the header counts tasks, levels, and members. That is spydr's graph language
94
+ in the harness's own CSS: no canvas, no graph library, no extra dependency. Its copy
95
+ ships in English and Chinese, from the same dictionaries as the card.
96
+
97
+ The roster ceiling is Agent Teams' `maxMembers`, not this plugin's; raise it in
98
+ the same profile patch (the bundled default is 8, i.e. the lead plus 7
99
+ teammates) so a wide decomposition can keep every ready leaf in flight.
100
+
101
+ The values come from the session's own `agentTeam` projection, which the
102
+ harness's Agent Teams plugin already publishes, so the tree follows the run
103
+ live (no polling, no RPC, no host-half state) and costs nothing while the tab is
104
+ not selected. Open the lead session of a run; a session with no team shows the
105
+ empty state instead.
106
+
107
+ ## How it works
108
+
109
+ `/legion <task>` queues a kickoff relay as the agent's next turn. The relay
110
+ carries the decomposition protocol with the current settings inlined:
111
+
112
+ - split non-atomic tasks into `minSubtasks`-`maxSubtasks` subtasks; fewer than
113
+ `minSubtasks` means the task is a leaf;
114
+ - never decompose past `maxDepth` (`0` = no cap: split until a task is atomic);
115
+ - `workersPerTask` candidates per leaf, combined by `mergeStrategy`
116
+ (`best` = judge picks the strongest verbatim, `reconcile` = merge strengths);
117
+ - the authoring agent reviews each child result, up to `maxReviewRetries`
118
+ rework attempts before a task fails terminally;
119
+ - `requireHumanApproval` holds the root at an `/legion approve | reject` gate;
120
+ - `maxTasksPerRun` caps how many tasks one run may create (`0` = unlimited).
121
+
122
+ The tree lives on the shared team task board (`team_task_create` with
123
+ `blocked_by` edges), so flow guarantees are structural: tasks flow top-down
124
+ only, results bottom-up only, and siblings never exchange work, the same
125
+ guarantees spydr enforces in its data model. `status`, `stop`, and the
126
+ approval verbs read and steer that live team state; nothing is kept in
127
+ process memory, so a restart cannot strand a run.
128
+
129
+ ## Limits
130
+
131
+ - Attachments ride the kickoff only; any other verb with attachments is
132
+ rejected so the composer keeps the originals.
133
+ - `/legion stop` is lead-only, like every roster mutation in the Team domain.
134
+ Its reply counts the teammates that had a running turn; idle ones are
135
+ reported separately.
136
+ - `/legion approve` and `/legion reject` go to the Team Lead from any session
137
+ of the team, and are refused from a session outside one.
138
+ - Settings are read when a verb runs, not when the plugin loads: no restart,
139
+ no card reload needed.
140
+
141
+ ## Development
142
+
143
+ `bun test`: pure-logic tests (`parseLegion`, `clampSettings`, `buildKickoff`,
144
+ `formatStatus`, `formatConfig`, `relayText`), the view's tree fold
145
+ (`buildTaskTree`, loaded from the browser half with a stubbed module loader),
146
+ handler tests over a fake host context, and a real Cordis composition. `bun install --frozen-lockfile` first: the handler
147
+ and composition tests load the real module and need the dev dependencies.
148
+
149
+ dsh loads plugins on Node `^22.19.0 || >=24.0.0`; development and tests run on bun.
150
+
151
+ For local development, `dsh plugin --profile <name> add <path-to-checkout>`.
152
+
153
+ ## Licence
154
+
155
+ MIT. See `LICENSE`.
@@ -0,0 +1,22 @@
1
+ # The dsh-legion bundle patch: applied automatically when a profile lists
2
+ # this bundle (`dsh plugin add`/`update` appends the package to
3
+ # dsh.profile.bundles). Users override this row from their profile's own
4
+ # cordis.patch.yml (live-watched; dsh.profile.bundles is frozen at boot) with a
5
+ # `- id: legion` row, which replaces the row's whole `config`.
6
+ # `name` stays a bare package specifier: the browser half is served by the
7
+ # client module system, which resolves the Loader entry's package, reads its
8
+ # `dsh.client` manifest, and serves `exports["./client"]`.
9
+ # Do not also insert this same row into the profile patch: insert does not
10
+ # dedupe ids, and a second row would register the plugin twice.
11
+ - insert:
12
+ - id: legion
13
+ name: '@maci0/dsh-legion'
14
+ config:
15
+ minSubtasks: 2
16
+ maxSubtasks: 4
17
+ maxDepth: 0
18
+ workersPerTask: 1
19
+ mergeStrategy: best
20
+ maxReviewRetries: 2
21
+ requireHumanApproval: true
22
+ maxTasksPerRun: 0
package/icon.svg ADDED
@@ -0,0 +1,7 @@
1
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <rect x="6" y="7" width="7" height="7" rx="1.5" fill="#45D9E7"/>
3
+ <rect x="14.5" y="7" width="7" height="7" rx="1.5" fill="#7CB7FF"/>
4
+ <rect x="23" y="7" width="7" height="7" rx="1.5" fill="#145AF3"/>
5
+ <path d="M9.5 14v3.2h17V14" stroke="#145AF3" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
6
+ <rect x="7" y="20.5" width="22" height="8" rx="1.5" fill="#E8F4FF" stroke="#145AF3" stroke-width="1.6"/>
7
+ </svg>
package/index.js ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * dsh-legion: spydr-style recursive task decomposition for DeepSeek Harness.
3
+ *
4
+ * One `/legion` command with a verb grammar, backed by the harness's native
5
+ * Agent Teams instead of a private scheduler:
6
+ *
7
+ * /legion <task> queue the decomposition protocol (start)
8
+ * /legion status roster + shared task board
9
+ * /legion config effective settings
10
+ * /legion stop interrupt every teammate, tell the lead to halt
11
+ * /legion approve [note] answer the human root-approval gate
12
+ * /legion reject <reason> reject the root deliverable with a reason
13
+ *
14
+ * Configuration lives in the `legion` settings namespace (patch row or the
15
+ * Plugins-page card). It rides into the run inside the kickoff relay,
16
+ * so a change applies to the next run with no reload and costs no prompt
17
+ * on turns where no run is active.
18
+ *
19
+ * For local development, `dsh plugin --profile <name> add <path-to-checkout>`.
20
+ */
21
+ import z from '@deepseek-ai/schemastery'
22
+ import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
23
+ import {
24
+ USAGE,
25
+ buildKickoff,
26
+ clampSettings,
27
+ formatConfig,
28
+ formatStatus,
29
+ parseLegion,
30
+ relayText,
31
+ DEFAULTS,
32
+ } from './lib/logic.js'
33
+
34
+ export const name = 'legion'
35
+
36
+ // `commands` registers the verb grammar; `agentTeams` is an optional service
37
+ // reached through ctx.get, so a team-less composition still mounts the
38
+ // command and degrades per verb.
39
+ export const inject = ['commands']
40
+
41
+ /**
42
+ * Row schema; the Plugins-page card edits this shape. `.volatile()` lets a
43
+ * profile edit land without remounting the plugin.
44
+ */
45
+ export const Config = z.object({
46
+ minSubtasks: z.number().step(1).min(1).max(20).default(DEFAULTS.minSubtasks).volatile(),
47
+ maxSubtasks: z.number().step(1).min(1).max(20).default(DEFAULTS.maxSubtasks).volatile(),
48
+ maxDepth: z.number().step(1).min(0).max(20).default(DEFAULTS.maxDepth).volatile(),
49
+ workersPerTask: z.number().step(1).min(1).max(16).default(DEFAULTS.workersPerTask).volatile(),
50
+ mergeStrategy: z.union(['best', 'reconcile']).default(DEFAULTS.mergeStrategy).volatile(),
51
+ maxReviewRetries: z.number().step(1).min(0).max(20).default(DEFAULTS.maxReviewRetries).volatile(),
52
+ requireHumanApproval: z.boolean().default(DEFAULTS.requireHumanApproval).volatile(),
53
+ maxTasksPerRun: z.number().step(1).min(0).max(10000).default(DEFAULTS.maxTasksPerRun).volatile(),
54
+ })
55
+
56
+ /**
57
+ * Queue one relay message as the agent's next turn.
58
+ * @param {object} agent - the exact receiving agent.
59
+ * @param {string} text - the relay body.
60
+ * @param {readonly object[]} [attachments] - durable blocks carried first.
61
+ */
62
+ function relay(agent, text, attachments = []) {
63
+ agent.followup(createUserMessage({
64
+ content: [...attachments, { type: 'text', text }],
65
+ source: { kind: 'legion', form: 'relay' },
66
+ }))
67
+ }
68
+
69
+ /** Error for a verb that needs Agent Teams when the service is not mounted. */
70
+ const NO_TEAMS = 'Agent Teams is not mounted in this composition; /legion needs it. '
71
+ + 'Enable the experimental bundle: '
72
+ + 'dsh plugin --profile <name> add @deepseek-ai/dsh-experimental-agent-team-profile'
73
+
74
+ /**
75
+ * Resolve the team service's view for one agent, or the reason there is none.
76
+ * @param {object|undefined} teams - `ctx.agentTeams`, when mounted.
77
+ * @param {object} agent - the command's receiving agent.
78
+ * @returns {{ok: true, members: object[], tasks: object[]} |
79
+ * {ok: false, reason: string}}
80
+ */
81
+ function teamView(teams, agent) {
82
+ if (teams === undefined) return { ok: false, reason: NO_TEAMS }
83
+ const membership = teams.tryMembership?.(agent)
84
+ if (membership === undefined) {
85
+ return { ok: false, reason: 'This session is not part of a team. Start a run with /legion <task>.' }
86
+ }
87
+ return { ok: true, members: teams.listMembers(agent), tasks: teams.listTasks(agent) }
88
+ }
89
+
90
+ /**
91
+ * Resolve the Team Lead a root-approval verdict goes to.
92
+ * @param {object|undefined} teams - `ctx.agentTeams`, when mounted.
93
+ * @param {object} agent - the command's receiving agent.
94
+ * @returns {{ok: true, agent: object} | {ok: false, reason: string}}
95
+ */
96
+ function leadOf(teams, agent) {
97
+ if (teams === undefined) return { ok: false, reason: NO_TEAMS }
98
+ const membership = teams.tryMembership?.(agent)
99
+ if (membership === undefined) {
100
+ return { ok: false, reason: 'This session is not part of a team. Start a run with /legion <task>.' }
101
+ }
102
+ return { ok: true, agent: membership.root }
103
+ }
104
+
105
+ /** Unwrap a volatile config ref (anything with `get()`) to its current value. */
106
+ function plainConfig(value) {
107
+ if (value !== null && typeof value === 'object' && typeof value.get === 'function') return value.get()
108
+ return value
109
+ }
110
+
111
+ /** Snapshot every row field, unwrapping volatile refs. */
112
+ function liveConfig(config) {
113
+ const plain = {}
114
+ for (const [key, value] of Object.entries(config)) plain[key] = plainConfig(value)
115
+ return plain
116
+ }
117
+
118
+ /**
119
+ * Mount the plugin.
120
+ * @param {object} ctx - the host context.
121
+ * @param {object} [config] - the validated patch-row configuration.
122
+ */
123
+ export function apply(ctx, config = {}) {
124
+ // Volatile fields update this object in place. Read it when the command runs.
125
+ const source = () => clampSettings(liveConfig(config))
126
+
127
+ ctx.effect(() => {
128
+ const dispose = ctx.commands.register({
129
+ definitionId: 'dsh-legion:legion',
130
+ name: 'legion',
131
+ description: '⚔ Recursive task decomposition: /legion <task> starts a run; status | config | stop | approve [note] | reject <reason>',
132
+ input: { hint: '<task> | start <task> | status | config | stop | approve [note] | reject <reason>', attachments: true },
133
+ handler: (invocation) => legionHandler(invocation, ctx, source),
134
+ })
135
+ return () => dispose()
136
+ })
137
+ }
138
+
139
+ /**
140
+ * Run one `/legion` invocation.
141
+ * @param {object} invocation - the command invocation.
142
+ * @param {object} ctx - the host context.
143
+ * @param {() => object} source - the settings source reader.
144
+ * @returns {Promise<{kind: 'success'|'error', text?: string}>} the UI result.
145
+ */
146
+ function legionHandler(invocation, ctx, source) {
147
+ const parsed = parseLegion(invocation.rawInput)
148
+ const agent = invocation.agent
149
+ const teams = ctx.get?.('agentTeams')
150
+
151
+ // Attachments are admitted by the definition; only a starting run can carry
152
+ // them into the kickoff, so any other verb gives the originals back.
153
+ if (invocation.attachments?.length > 0 && parsed.kind !== 'start') {
154
+ return Promise.resolve({
155
+ kind: 'error',
156
+ text: 'Attachments only accompany a starting run (/legion <task>). Send the verb without them.',
157
+ })
158
+ }
159
+
160
+ switch (parsed.kind) {
161
+ case 'usage':
162
+ return Promise.resolve({ kind: 'error', text: USAGE })
163
+
164
+ case 'error':
165
+ return Promise.resolve({ kind: 'error', text: parsed.text })
166
+
167
+ case 'start': {
168
+ // The run lives on the team board; without it the kickoff would name
169
+ // tools the model does not have.
170
+ if (teams === undefined) return Promise.resolve({ kind: 'error', text: NO_TEAMS })
171
+ relay(agent, buildKickoff(parsed.task, source()), invocation.attachments ?? [])
172
+ const c = clampSettings(source())
173
+ return Promise.resolve({
174
+ kind: 'success',
175
+ text: `Legion run started: "${parsed.task}". Settings: ${c.maxDepth === 0 ? 'no depth cap' : `up to ${c.maxDepth} level(s) deep`}, `
176
+ + `${c.minSubtasks}-${c.maxSubtasks} subtasks per split, ${c.workersPerTask} worker(s) per leaf, `
177
+ + `human root approval ${c.requireHumanApproval ? 'required' : 'off'}. Track it with /legion status.`,
178
+ })
179
+ }
180
+
181
+ case 'status': {
182
+ const view = teamView(teams, agent)
183
+ if (!view.ok) return Promise.resolve({ kind: 'error', text: view.reason })
184
+ return Promise.resolve({ kind: 'success', text: formatStatus(view) })
185
+ }
186
+
187
+ case 'config':
188
+ return Promise.resolve({ kind: 'success', text: formatConfig(source()) })
189
+
190
+ case 'stop': {
191
+ const view = teamView(teams, agent)
192
+ if (!view.ok) return Promise.resolve({ kind: 'error', text: view.reason })
193
+ const membership = teams.tryMembership(agent)
194
+ if (membership.role !== 'lead') {
195
+ return Promise.resolve({ kind: 'error', text: 'Only the Team Lead can stop a legion run.' })
196
+ }
197
+ let interrupted = 0
198
+ let idle = 0
199
+ for (const member of view.members) {
200
+ if (member.role === 'teammate') {
201
+ try {
202
+ // `previousStatus` is sampled before the cancel: an inactive
203
+ // teammate had no turn to stop.
204
+ if (teams.interrupt(agent, member.name).previousStatus === 'running') interrupted += 1
205
+ else idle += 1
206
+ } catch {
207
+ // A teammate that settled between the roster read and the interrupt
208
+ // needs no report: the run is being stopped either way.
209
+ }
210
+ }
211
+ }
212
+ relay(agent, relayText('stop'))
213
+ return Promise.resolve({
214
+ kind: 'success',
215
+ text: `Stopped ${interrupted} running teammate(s)${idle > 0 ? `, ${idle} already idle` : ''}; `
216
+ + 'the lead was told to halt and summarize.',
217
+ })
218
+ }
219
+
220
+ case 'approve': {
221
+ const lead = leadOf(teams, agent)
222
+ if (!lead.ok) return Promise.resolve({ kind: 'error', text: lead.reason })
223
+ relay(lead.agent, relayText('approve', parsed.note))
224
+ return Promise.resolve({
225
+ kind: 'success',
226
+ text: parsed.note
227
+ ? `Approval relayed to the lead with your note: "${parsed.note}".`
228
+ : 'Approval relayed to the lead; the run closes.',
229
+ })
230
+ }
231
+
232
+ case 'reject': {
233
+ const lead = leadOf(teams, agent)
234
+ if (!lead.ok) return Promise.resolve({ kind: 'error', text: lead.reason })
235
+ relay(lead.agent, relayText('reject', parsed.reason))
236
+ return Promise.resolve({
237
+ kind: 'success',
238
+ text: `Rejection relayed to the lead: "${parsed.reason}". The deliverable goes back for rework.`,
239
+ })
240
+ }
241
+
242
+ default:
243
+ return Promise.resolve({ kind: 'error', text: USAGE })
244
+ }
245
+ }