@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/lib/logic.js ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * dsh-legion pure logic, shared by the host half and the tests.
3
+ *
4
+ * No imports on purpose: the unit tests run with bare `bun test`, no
5
+ * node_modules required. Everything here is a function of its arguments:
6
+ * command parsing, settings clamping, the kickoff protocol text, and the
7
+ * status/config renderers the `/legion` command returns.
8
+ */
9
+
10
+ /** Field defaults; also the schema defaults and the patch-row entry in `index.js`. */
11
+ export const DEFAULTS = Object.freeze({
12
+ minSubtasks: 2,
13
+ maxSubtasks: 4,
14
+ maxDepth: 0,
15
+ workersPerTask: 1,
16
+ mergeStrategy: 'best',
17
+ maxReviewRetries: 2,
18
+ requireHumanApproval: true,
19
+ maxTasksPerRun: 0,
20
+ })
21
+
22
+ /** Per-field bounds shared by the host schema and the settings card. */
23
+ export const LIMITS = Object.freeze({
24
+ minSubtasks: [1, 20],
25
+ maxSubtasks: [1, 20],
26
+ /** 0 = no depth cap: decompose until the task is atomic. */
27
+ maxDepth: [0, 20],
28
+ workersPerTask: [1, 16],
29
+ maxReviewRetries: [0, 20],
30
+ maxTasksPerRun: [0, 10000],
31
+ })
32
+
33
+ /** Usage line returned for `/legion` with no arguments or a bad verb. */
34
+ export const USAGE = 'Usage: /legion <task> starts a run (or /legion start <task>). '
35
+ + 'Verbs: /legion status (roster + task board), /legion config (effective settings), '
36
+ + '/legion stop (interrupt teammates), /legion approve [note], /legion reject <reason>. '
37
+ + 'A verb is matched whole; a task that starts with a verb word uses /legion start.'
38
+
39
+ /**
40
+ * Parse the text after `/legion`.
41
+ *
42
+ * Verbs are exact whole-input matches (case-insensitive) except `start`,
43
+ * `approve`, and `reject`, which take a suffix. Everything else is a task
44
+ * title, so `/legion status page redesign` starts a run rather than guessing.
45
+ *
46
+ * @param {string} input - raw text after the command name.
47
+ * @returns {{kind: 'usage'} | {kind: 'error', text: string} |
48
+ * {kind: 'status'} | {kind: 'config'} | {kind: 'stop'} |
49
+ * {kind: 'approve', note: string} | {kind: 'reject', reason: string} |
50
+ * {kind: 'start', task: string}}
51
+ */
52
+ export function parseLegion(input) {
53
+ const trimmed = String(input ?? '').trim()
54
+ if (trimmed === '') return { kind: 'usage' }
55
+ const lower = trimmed.toLowerCase()
56
+ if (lower === 'status') return { kind: 'status' }
57
+ if (lower === 'config') return { kind: 'config' }
58
+ if (lower === 'stop') return { kind: 'stop' }
59
+
60
+ const approve = /^approve(?:\s+([\s\S]+))?$/i.exec(trimmed)
61
+ if (approve) return { kind: 'approve', note: (approve[1] ?? '').trim() }
62
+
63
+ const reject = /^reject(?:\s+([\s\S]+))?$/i.exec(trimmed)
64
+ if (reject) {
65
+ const reason = (reject[1] ?? '').trim()
66
+ if (reason === '') {
67
+ return { kind: 'error', text: 'Usage: /legion reject <reason> (say what the root deliverable must change).' }
68
+ }
69
+ return { kind: 'reject', reason }
70
+ }
71
+
72
+ const start = /^start\s+([\s\S]+)$/i.exec(trimmed)
73
+ if (start) {
74
+ const task = start[1].trim()
75
+ if (task === '') return { kind: 'usage' }
76
+ return { kind: 'start', task }
77
+ }
78
+ return { kind: 'start', task: trimmed }
79
+ }
80
+
81
+ /**
82
+ * Clamp a settings value to the bounds the schema and the card enforce.
83
+ *
84
+ * `maxSubtasks` is raised to `minSubtasks` when a hand-edit inverted them:
85
+ * a schema cannot express that cross-field rule, and refusing the section
86
+ * would wedge the card, so the kickoff reads a usable pair either way.
87
+ *
88
+ * @param {unknown} raw - the resolved settings section (may be partial).
89
+ * @returns {typeof DEFAULTS} a complete, in-bounds settings value.
90
+ */
91
+ export function clampSettings(raw) {
92
+ const s = raw !== null && typeof raw === 'object' ? raw : {}
93
+ const int = (value, key, fallback) => {
94
+ const [lo, hi] = LIMITS[key]
95
+ const n = Math.trunc(Number(value))
96
+ return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : fallback
97
+ }
98
+ const minSubtasks = int(s.minSubtasks, 'minSubtasks', DEFAULTS.minSubtasks)
99
+ return {
100
+ minSubtasks,
101
+ maxSubtasks: Math.max(minSubtasks, int(s.maxSubtasks, 'maxSubtasks', DEFAULTS.maxSubtasks)),
102
+ maxDepth: int(s.maxDepth, 'maxDepth', DEFAULTS.maxDepth),
103
+ workersPerTask: int(s.workersPerTask, 'workersPerTask', DEFAULTS.workersPerTask),
104
+ mergeStrategy: typeof s.mergeStrategy === 'string' && s.mergeStrategy.toLowerCase() === 'reconcile'
105
+ ? 'reconcile'
106
+ : 'best',
107
+ maxReviewRetries: int(s.maxReviewRetries, 'maxReviewRetries', DEFAULTS.maxReviewRetries),
108
+ requireHumanApproval: typeof s.requireHumanApproval === 'boolean'
109
+ ? s.requireHumanApproval
110
+ : DEFAULTS.requireHumanApproval,
111
+ maxTasksPerRun: int(s.maxTasksPerRun, 'maxTasksPerRun', DEFAULTS.maxTasksPerRun),
112
+ }
113
+ }
114
+
115
+ /**
116
+ * The kickoff protocol queued to the agent when a run starts.
117
+ *
118
+ * The configuration rides in this message instead of a system-prompt
119
+ * section: it applies to the run being started, costs nothing on turns
120
+ * where no run is active, and a settings change needs no reload.
121
+ *
122
+ * @param {string} task - the root task title as the human typed it.
123
+ * @param {unknown} raw - resolved settings (clamped here).
124
+ * @returns {string} the relay message text.
125
+ */
126
+ export function buildKickoff(task, raw) {
127
+ const c = clampSettings(raw)
128
+ const approval = c.requireHumanApproval
129
+ ? 'required: when the root deliverable is ready, stop and wait for the human '
130
+ + 'to answer /legion approve or /legion reject <reason>.'
131
+ : 'not required: close the root yourself once every child task is completed.'
132
+ const budget = c.maxTasksPerRun === 0 ? 'unlimited' : `${c.maxTasksPerRun} task(s)`
133
+ return [
134
+ '[legion] New root task. Run the legion decomposition protocol for this session.',
135
+ '',
136
+ `Task: ${task}`,
137
+ '',
138
+ 'Decomposition rules (effective now):',
139
+ `- Split a non-atomic task into ${c.minSubtasks}-${c.maxSubtasks} subtasks; fewer than ${c.minSubtasks} proposed subtasks means the task is a leaf.`,
140
+ c.maxDepth === 0
141
+ ? '- Decomposition depth cap: none. Split until a task is atomic, however deep that goes.'
142
+ : `- Decomposition depth cap: ${c.maxDepth}. Never decompose past it.`,
143
+ `- Execution: ${c.workersPerTask} worker(s) per leaf. Combine multiple workers with the "${c.mergeStrategy}" strategy:`,
144
+ c.mergeStrategy === 'reconcile'
145
+ ? ' reconcile: merge the strengths of every candidate into one deliverable.'
146
+ : ' best: a judge picks the strongest candidate verbatim.',
147
+ `- Review: the agent that authored a task reviews each child result; at most ${c.maxReviewRetries} rework attempts before a task fails terminally.`,
148
+ '- Fan out: spawn one teammate per ready leaf and keep every independent leaf in flight at once; the team roster cap is the only limit on how many run together.',
149
+ `- Human root approval: ${approval}`,
150
+ `- Task budget for this run: ${budget}.`,
151
+ '',
152
+ 'Flow guarantees: tasks flow top down only (parents create children), results flow',
153
+ 'bottom up only (a completed child unblocks its parent, which consolidates the',
154
+ 'children results into its own deliverable), and siblings never exchange work.',
155
+ 'Model the tree on the shared team task board: team_task_create with blocked_by',
156
+ 'edges, spawn_teammate for leaf workers, send_message for handoffs, team_task_update',
157
+ 'as status changes. Report progress with /legion status; the human can end the run',
158
+ 'with /legion stop.',
159
+ ].join('\n')
160
+ }
161
+
162
+ /**
163
+ * Render the effective settings for `/legion config`.
164
+ * @param {unknown} raw - resolved settings (clamped here).
165
+ * @returns {string} one line per setting plus the edit hint.
166
+ */
167
+ export function formatConfig(raw) {
168
+ const c = clampSettings(raw)
169
+ return [
170
+ 'Legion configuration (effective):',
171
+ `- min subtasks: ${c.minSubtasks}, max subtasks: ${c.maxSubtasks}, max depth: ${c.maxDepth === 0 ? 'unlimited' : c.maxDepth}`,
172
+ `- workers per leaf: ${c.workersPerTask}, merge strategy: ${c.mergeStrategy}`,
173
+ `- max review retries: ${c.maxReviewRetries}`,
174
+ `- human root approval: ${c.requireHumanApproval ? 'required' : 'not required'}`,
175
+ `- task budget per run: ${c.maxTasksPerRun === 0 ? 'unlimited' : c.maxTasksPerRun}`,
176
+ "Edit in the Plugins page, on the Legion row's Configure control; changes apply to the next run.",
177
+ ].join('\n')
178
+ }
179
+
180
+ /** Verbose spelling of a durable task status for the status renderer. */
181
+ const TASK_STATUS = { pending: 'pending', in_progress: 'in progress', completed: 'completed' }
182
+
183
+ /**
184
+ * Render the roster and the task board for `/legion status`.
185
+ *
186
+ * Task lines cap at 40: a runaway decomposition would otherwise dump the
187
+ * whole board into the composer result (upgrade path: paginate if boards
188
+ * that large become normal).
189
+ *
190
+ * @param {{members?: readonly object[], tasks?: readonly object[]}} view -
191
+ * `listMembers` + `listTasks` output for the caller's team.
192
+ * @returns {string} the human-readable status report.
193
+ */
194
+ export function formatStatus(view) {
195
+ const members = Array.isArray(view?.members) ? view.members : []
196
+ const tasks = Array.isArray(view?.tasks) ? view.tasks : []
197
+ const counts = { completed: 0, in_progress: 0, pending: 0, deleted: 0, ready: 0 }
198
+ for (const task of tasks) {
199
+ if (counts[task.status] !== undefined) counts[task.status] += 1
200
+ if (task.status === 'pending' && task.ready === true) counts.ready += 1
201
+ }
202
+
203
+ const lines = []
204
+ lines.push(`Legion status: ${members.length} member(s), ${tasks.length} task(s).`)
205
+ lines.push(`Roster: ${members.length === 0 ? '(none)' : members
206
+ .map((m) => `${m.name} (${m.role}, ${m.status})`)
207
+ .join(', ')}`)
208
+ lines.push(`Tasks: ${counts.completed} completed, ${counts.in_progress} in progress, `
209
+ + `${counts.pending} pending (${counts.ready} ready).`)
210
+
211
+ const shown = tasks.slice(0, 40)
212
+ for (const task of shown) lines.push(taskLine(task))
213
+ if (tasks.length > shown.length) lines.push(`… and ${tasks.length - shown.length} more task(s).`)
214
+ return lines.join('\n')
215
+ }
216
+
217
+ /**
218
+ * One board row: id, status, subject, then the owner or the blockers.
219
+ * @param {object} task - one `TeamTaskView`.
220
+ * @returns {string} the row.
221
+ */
222
+ function taskLine(task) {
223
+ const status = TASK_STATUS[task.status] ?? String(task.status)
224
+ let suffix = ''
225
+ if (task.status === 'in_progress') suffix = `, owner ${task.ownerName ?? 'unknown'}`
226
+ else if (task.status === 'pending' && task.blockedBy?.length > 0) {
227
+ suffix = task.ready === true ? ', ready' : `, blocked by ${task.blockedBy.join(', ')}`
228
+ } else if (task.status === 'pending') suffix = ', ready'
229
+ return ` ${task.id} [${status}] ${task.subject}${suffix}`
230
+ }
231
+
232
+ /** One relay line queued to the agent by an approval-gate or stop verb. */
233
+ export function relayText(kind, payload) {
234
+ if (kind === 'approve') {
235
+ return payload
236
+ ? `[legion] Human approved the root deliverable. Note: ${payload}`
237
+ : '[legion] Human approved the root deliverable. Close the run.'
238
+ }
239
+ if (kind === 'reject') {
240
+ return `[legion] Human rejected the root deliverable: ${payload}. `
241
+ + 'Address the reason, then present the result again for approval.'
242
+ }
243
+ return '[legion] Human ended the run. Do not spawn new teammates or tasks. '
244
+ + 'Summarize where the work stopped and what remains.'
245
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Legion",
4
+ "description": "Recursive task decomposition over Agent Teams, started with /legion."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "军团",
4
+ "description": "在 Agent Teams 上做递归任务分解,用 /legion 启动。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,92 @@
1
1
  {
2
2
  "name": "@maci0/dsh-legion",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.8.2",
4
+ "publishConfig": {
5
+ "access": "public",
6
+ "registry": "https://registry.npmjs.org/"
7
+ },
8
+ "type": "module",
9
+ "main": "index.js",
10
+ "description": "Legion recursive task decomposition for DeepSeek Harness: /legion runs spydr-style decomposition over native Agent Teams, with a Plugin configuration card.",
11
+ "license": "MIT",
12
+ "exports": {
13
+ ".": "./index.js",
14
+ "./client": {
15
+ "default": "./lib/client.js"
16
+ },
17
+ "./cordis.patch.yml": "./cordis.patch.yml",
18
+ "./locale/*.json": "./locale/*.json",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "dsh": {
22
+ "bundle": {
23
+ "patch": "./cordis.patch.yml"
24
+ },
25
+ "client": {
26
+ "platform": "web",
27
+ "inject": [
28
+ "@deepseek-ai/dsh-client-locale",
29
+ "@deepseek-ai/dsh-client-ui-conversation",
30
+ "@deepseek-ai/dsh-client-ui-renderer",
31
+ "@deepseek-ai/dsh-client-ui-settings",
32
+ "@deepseek-ai/dsh-client-ui-plugin-manager"
33
+ ]
34
+ },
35
+ "compatibility": {
36
+ "dsh": ">=0.2.0-rc.2 <0.3.0"
37
+ }
38
+ },
39
+ "files": [
40
+ "icon.svg",
41
+ "locale/*.json",
42
+ "index.js",
43
+ "lib/logic.js",
44
+ "lib/client.js",
45
+ "cordis.patch.yml",
46
+ "README.md",
47
+ "LICENSE"
48
+ ],
49
+ "scripts": {
50
+ "test": "bun test",
51
+ "test:node": "node --test tests/*.test.*"
52
+ },
53
+ "engines": {
54
+ "node": "^22.19.0 || >=24.0.0"
55
+ },
56
+ "peerDependencies": {
57
+ "@deepseek-ai/dsh-client-locale": "*",
58
+ "@deepseek-ai/dsh-client-ui-conversation": "*",
59
+ "@deepseek-ai/dsh-client-ui-renderer": "*",
60
+ "@deepseek-ai/dsh-client-ui-settings": "*",
61
+ "@deepseek-ai/dsh-client-ui-plugin-manager": "*"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "@deepseek-ai/dsh-client-locale": {
65
+ "optional": true
66
+ },
67
+ "@deepseek-ai/dsh-client-ui-conversation": {
68
+ "optional": true
69
+ },
70
+ "@deepseek-ai/dsh-client-ui-renderer": {
71
+ "optional": true
72
+ },
73
+ "@deepseek-ai/dsh-client-ui-settings": {
74
+ "optional": true
75
+ },
76
+ "@deepseek-ai/dsh-client-ui-plugin-manager": {
77
+ "optional": true
78
+ }
79
+ },
80
+ "repository": {
81
+ "type": "git",
82
+ "url": "https://github.com/maci0/dsh-legion.git"
83
+ },
84
+ "devDependencies": {
85
+ "@deepseek-ai/cordis": "4.0.5-alpha.1"
86
+ },
87
+ "icon": "./icon.svg",
88
+ "dependencies": {
89
+ "@deepseek-ai/dsh-llm": "0.2.1-alpha.2",
90
+ "@deepseek-ai/schemastery": "3.18.5-alpha.1"
91
+ }
92
+ }