@raidou/pi-pm-subagents 0.1.1

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.
Files changed (69) hide show
  1. package/.prettierrc +7 -0
  2. package/AGENTS.md +1 -0
  3. package/README.md +85 -0
  4. package/README.zh-CN.md +85 -0
  5. package/agents/explorer.md +10 -0
  6. package/agents/planner.md +16 -0
  7. package/agents/researcher.md +22 -0
  8. package/agents/reviewer.md +10 -0
  9. package/eslint.config.mjs +14 -0
  10. package/example-prompts/coordinator.md +21 -0
  11. package/package.json +48 -0
  12. package/pm-subagents-prompts/coordinator.md +16 -0
  13. package/pnpm-workspace.yaml +5 -0
  14. package/src/bash-readonly.test.ts +331 -0
  15. package/src/bash-readonly.ts +205 -0
  16. package/src/coordinator/coordinator.test.ts +28 -0
  17. package/src/coordinator/coordinator.ts +275 -0
  18. package/src/custom-select.test.ts +91 -0
  19. package/src/custom-select.ts +209 -0
  20. package/src/index.ts +87 -0
  21. package/src/models-config/models-config.test.ts +88 -0
  22. package/src/models-config/models-config.ts +205 -0
  23. package/src/models-config/scoped-models-editor.test.ts +189 -0
  24. package/src/models-config/scoped-models-editor.ts +412 -0
  25. package/src/models-config/subagent-model-constants.ts +2 -0
  26. package/src/models-config/subagent-model-cycle.ts +53 -0
  27. package/src/models-config/subagent-model-utils.test.ts +250 -0
  28. package/src/models-config/subagent-model-utils.ts +52 -0
  29. package/src/pm-mode.test.ts +324 -0
  30. package/src/pm-mode.ts +142 -0
  31. package/src/prompts/mode.test.ts +289 -0
  32. package/src/prompts/mode.ts +31 -0
  33. package/src/prompts/roles.test.ts +724 -0
  34. package/src/prompts/roles.ts +119 -0
  35. package/src/subagent/activity.test.ts +230 -0
  36. package/src/subagent/activity.ts +60 -0
  37. package/src/subagent/batcher.test.ts +198 -0
  38. package/src/subagent/batcher.ts +51 -0
  39. package/src/subagent/consts.ts +1 -0
  40. package/src/subagent/demo.ts +773 -0
  41. package/src/subagent/fleet.test.ts +1758 -0
  42. package/src/subagent/fleet.ts +376 -0
  43. package/src/subagent/identity.test.ts +31 -0
  44. package/src/subagent/identity.ts +16 -0
  45. package/src/subagent/manager.test.ts +392 -0
  46. package/src/subagent/manager.ts +277 -0
  47. package/src/subagent/tools.ts +314 -0
  48. package/src/subagent/viewer.ts +305 -0
  49. package/src/types.ts +15 -0
  50. package/src/ui/border-view.ts +50 -0
  51. package/src/ui/review-pager.ts +146 -0
  52. package/src/ui/scroll-view.test.ts +190 -0
  53. package/src/ui/scroll-view.ts +155 -0
  54. package/src/utils/format.test.ts +76 -0
  55. package/src/utils/format.ts +67 -0
  56. package/src/utils/fs.ts +9 -0
  57. package/src/utils/markdown.test.ts +442 -0
  58. package/src/utils/markdown.ts +79 -0
  59. package/src/utils/messages.test.ts +436 -0
  60. package/src/utils/messages.ts +131 -0
  61. package/src/utils/model-ref.test.ts +42 -0
  62. package/src/utils/model-ref.ts +44 -0
  63. package/src/utils/state.test.ts +98 -0
  64. package/src/utils/state.ts +45 -0
  65. package/src/utils/tools.ts +48 -0
  66. package/src/utils/truncate.test.ts +41 -0
  67. package/src/utils/truncate.ts +59 -0
  68. package/tsconfig.json +24 -0
  69. package/vitest.config.ts +8 -0
@@ -0,0 +1,724 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6
+
7
+ import { BASH_READONLY_TOOL_NAME } from '../bash-readonly.js'
8
+ import {
9
+ clearRoles,
10
+ listRoles,
11
+ loadRoles,
12
+ resolveRole,
13
+ rolesDescription,
14
+ } from './roles.js'
15
+
16
+ const MOCK_AGENT_DIR_VAR = 'PI_CODING_AGENT_DIR'
17
+
18
+ // Roles bundled with the plugin in the repo-root agents/ directory.
19
+ // Tests run inside the repo, so these are always loaded (unless skipped).
20
+ const BUNDLED_ROLES = ['explorer', 'planner', 'researcher', 'reviewer']
21
+
22
+ describe('roles', () => {
23
+ let originalAgentDir: string | undefined
24
+
25
+ beforeEach(() => {
26
+ originalAgentDir = process.env[MOCK_AGENT_DIR_VAR]
27
+ clearRoles()
28
+ })
29
+
30
+ afterEach(() => {
31
+ process.env[MOCK_AGENT_DIR_VAR] = originalAgentDir
32
+ clearRoles()
33
+ })
34
+
35
+ describe('loadRoles', () => {
36
+ describe('built-in roles', () => {
37
+ it('includes worker with empty systemPrompt', async () => {
38
+ await loadRoles('/fake/cwd')
39
+ const worker = resolveRole('worker')
40
+ expect(worker.systemPrompt).toBe('')
41
+ expect(worker.fm.tools).toBeUndefined()
42
+ expect(worker.fm.removeTools).toBeUndefined()
43
+ expect(worker.fm.model).toBeUndefined()
44
+ expect(worker.fm.reviewOnEnd).toBeUndefined()
45
+ })
46
+
47
+ it('includes planner role with reviewOnEnd enabled', async () => {
48
+ await loadRoles('/fake/cwd')
49
+ const planner = resolveRole('planner')
50
+ expect(planner.systemPrompt).toContain('read-only exploration mode')
51
+ expect(planner.systemPrompt).toContain(
52
+ 'Produce a concrete implementation plan as Markdown',
53
+ )
54
+ expect(planner.fm.reviewOnEnd).toBe(true)
55
+ expect(planner.fm.removeTools).toEqual(['write', 'edit', 'bash'])
56
+ expect(planner.fm.description).toBe(
57
+ 'Produces an implementation plan, the plan is reviewed by the user before implementation',
58
+ )
59
+ })
60
+
61
+ it('returns built-ins and plugin roles when no user directories exist', async () => {
62
+ const tempDir = await mkdtemp(
63
+ join(tmpdir(), 'pi-pm-subagents-test-empty-'),
64
+ )
65
+ try {
66
+ await loadRoles(tempDir)
67
+ // Tests run inside the repo, so the bundled agents/ directory
68
+ // (explorer, planner, researcher, reviewer) is picked up.
69
+ expect(listRoles()[0]).toBe('worker')
70
+ expect(new Set(listRoles())).toEqual(
71
+ new Set(['worker', ...BUNDLED_ROLES]),
72
+ )
73
+ resolveRole('worker')
74
+ resolveRole('planner')
75
+ } finally {
76
+ await rm(tempDir, { recursive: true, force: true })
77
+ }
78
+ })
79
+
80
+ it('resolves plugin roles from the bundled agents directory', async () => {
81
+ await loadRoles('/fake/cwd')
82
+ const planner = resolveRole('planner')
83
+ expect(planner.systemPrompt).toContain('read-only exploration mode')
84
+ expect(planner.fm.removeTools).toEqual(['write', 'edit', 'bash'])
85
+ expect(planner.fm.extraTools).toEqual([BASH_READONLY_TOOL_NAME])
86
+ expect(planner.fm.reviewOnEnd).toBe(true)
87
+ expect(() => resolveRole('explorer')).not.toThrow()
88
+ expect(() => resolveRole('researcher')).not.toThrow()
89
+ expect(() => resolveRole('reviewer')).not.toThrow()
90
+ })
91
+ })
92
+
93
+ describe('project-level roles', () => {
94
+ let tempDir: string
95
+ let agentsDir: string
96
+
97
+ beforeEach(async () => {
98
+ tempDir = await mkdtemp(join(tmpdir(), 'pi-pm-subagents-test-project-'))
99
+ agentsDir = join(tempDir, '.pi', 'agents')
100
+ await mkdir(agentsDir, { recursive: true })
101
+ })
102
+
103
+ afterEach(async () => {
104
+ await rm(tempDir, { recursive: true, force: true })
105
+ })
106
+
107
+ it('loads all roles from project .pi/agents directory', async () => {
108
+ await writeFile(
109
+ join(agentsDir, 'code-improver.md'),
110
+ `---
111
+ description: Scans files and suggests improvements.
112
+ tools: [read, grep, glob]
113
+ model: sonnet
114
+ ---
115
+ You are a code improver.`,
116
+ )
117
+ await writeFile(
118
+ join(agentsDir, 'tester.md'),
119
+ `---
120
+ description: Runs tests.
121
+ tools: [test]
122
+ ---
123
+ You are a tester.`,
124
+ )
125
+
126
+ await loadRoles(tempDir)
127
+ // worker + bundled plugin roles + 2 project roles
128
+ expect(listRoles()).toHaveLength(1 + BUNDLED_ROLES.length + 2)
129
+ expect(() => resolveRole('worker')).not.toThrow()
130
+ expect(() => resolveRole('planner')).not.toThrow()
131
+ expect(() => resolveRole('code-improver')).not.toThrow()
132
+ expect(() => resolveRole('tester')).not.toThrow()
133
+ })
134
+
135
+ it('parses role frontmatter correctly (tools as array)', async () => {
136
+ const rolePath = join(agentsDir, 'code-improver.md')
137
+ await writeFile(
138
+ rolePath,
139
+ `---
140
+ description: Scans files and suggests improvements.
141
+ tools: [read, grep, glob]
142
+ model: sonnet
143
+ ---
144
+ You are a code improver.`,
145
+ )
146
+
147
+ await loadRoles(tempDir)
148
+ const role = resolveRole('code-improver')
149
+ expect(role.fm.description).toBe(
150
+ 'Scans files and suggests improvements.',
151
+ )
152
+ expect(role.fm.tools).toEqual(['read', 'grep', 'glob'])
153
+ expect(role.fm.model).toBe('sonnet')
154
+ expect(role.systemPrompt).toBe('You are a code improver.')
155
+ })
156
+
157
+ it('handles empty tools array', async () => {
158
+ const rolePath = join(agentsDir, 'no-tools.md')
159
+ await writeFile(
160
+ rolePath,
161
+ `---
162
+ tools: []
163
+ ---
164
+ No tools needed.`,
165
+ )
166
+
167
+ await loadRoles(tempDir)
168
+ expect(resolveRole('no-tools').fm.tools).toEqual([])
169
+ })
170
+
171
+ it('parses extraTools as array in frontmatter', async () => {
172
+ const rolePath = join(agentsDir, 'extra-array.md')
173
+ await writeFile(
174
+ rolePath,
175
+ `---
176
+ extraTools: [bash_readonly, grep]
177
+ ---
178
+ You have extra tools.`,
179
+ )
180
+
181
+ await loadRoles(tempDir)
182
+ const role = resolveRole('extra-array')
183
+ expect(role.fm.extraTools).toEqual([BASH_READONLY_TOOL_NAME, 'grep'])
184
+ })
185
+
186
+ it('handles empty extraTools array', async () => {
187
+ const rolePath = join(agentsDir, 'no-extra.md')
188
+ await writeFile(
189
+ rolePath,
190
+ `---
191
+ extraTools: []
192
+ ---
193
+ No extra tools needed.`,
194
+ )
195
+
196
+ await loadRoles(tempDir)
197
+ expect(resolveRole('no-extra').fm.extraTools).toEqual([])
198
+ })
199
+
200
+ it('parses removeTools as array in frontmatter', async () => {
201
+ const rolePath = join(agentsDir, 'remove-array.md')
202
+ await writeFile(
203
+ rolePath,
204
+ `---
205
+ removeTools: [write, bash]
206
+ ---
207
+ You have removed tools.`,
208
+ )
209
+
210
+ await loadRoles(tempDir)
211
+ const role = resolveRole('remove-array')
212
+ expect(role.fm.removeTools).toEqual(['write', 'bash'])
213
+ })
214
+
215
+ it('handles empty removeTools array', async () => {
216
+ const rolePath = join(agentsDir, 'no-remove.md')
217
+ await writeFile(
218
+ rolePath,
219
+ `---
220
+ removeTools: []
221
+ ---
222
+ No tools to remove.`,
223
+ )
224
+
225
+ await loadRoles(tempDir)
226
+ expect(resolveRole('no-remove').fm.removeTools).toEqual([])
227
+ })
228
+
229
+ it('tools field is not affected by extraTools', async () => {
230
+ const rolePath = join(agentsDir, 'both-tools.md')
231
+ await writeFile(
232
+ rolePath,
233
+ `---
234
+ tools: [read, write]
235
+ extraTools: [grep, glob]
236
+ ---
237
+ You have both tools.`,
238
+ )
239
+
240
+ await loadRoles(tempDir)
241
+ const role = resolveRole('both-tools')
242
+ expect(role.fm.tools).toEqual(['read', 'write'])
243
+ expect(role.fm.extraTools).toEqual(['grep', 'glob'])
244
+ })
245
+
246
+ it('skips non-.md files', async () => {
247
+ await writeFile(
248
+ join(agentsDir, 'not-a-role.txt'),
249
+ 'This should be ignored.',
250
+ )
251
+ await writeFile(join(agentsDir, 'valid.md'), 'This should be loaded.')
252
+
253
+ await loadRoles(tempDir)
254
+ // worker + bundled plugin roles + 1 valid project role
255
+ expect(listRoles()).toHaveLength(1 + BUNDLED_ROLES.length + 1)
256
+ expect(() => resolveRole('valid')).not.toThrow()
257
+ expect(() => resolveRole('not-a-role')).toThrow()
258
+ })
259
+ })
260
+
261
+ describe('global-level roles', () => {
262
+ let tempDir: string
263
+ let globalTempDir: string
264
+ let globalAgentsDir: string
265
+
266
+ beforeEach(async () => {
267
+ tempDir = await mkdtemp(
268
+ join(tmpdir(), 'pi-pm-subagents-test-global-cwd-'),
269
+ )
270
+ globalTempDir = await mkdtemp(
271
+ join(tmpdir(), 'pi-pm-subagents-test-global-dir-'),
272
+ )
273
+ globalAgentsDir = join(globalTempDir, 'agents')
274
+ await mkdir(globalAgentsDir, { recursive: true })
275
+ process.env[MOCK_AGENT_DIR_VAR] = globalTempDir
276
+ })
277
+
278
+ afterEach(async () => {
279
+ await rm(tempDir, { recursive: true, force: true })
280
+ await rm(globalTempDir, { recursive: true, force: true })
281
+ })
282
+
283
+ it('loads role from global agents directory', async () => {
284
+ const rolePath = join(globalAgentsDir, 'global-role.md')
285
+ await writeFile(
286
+ rolePath,
287
+ `---
288
+ description: A global role.
289
+ ---
290
+ Global role content.`,
291
+ )
292
+
293
+ await loadRoles(tempDir)
294
+ const role = resolveRole('global-role')
295
+ expect(role.fm.description).toBe('A global role.')
296
+ expect(role.systemPrompt).toBe('Global role content.')
297
+ })
298
+
299
+ it('loads all roles from global directory', async () => {
300
+ await writeFile(
301
+ join(globalAgentsDir, 'global-role.md'),
302
+ `---
303
+ description: First global.
304
+ ---
305
+ First global content.`,
306
+ )
307
+ await writeFile(
308
+ join(globalAgentsDir, 'another-global.md'),
309
+ `---
310
+ description: Second global.
311
+ ---
312
+ Second global content.`,
313
+ )
314
+
315
+ await loadRoles(tempDir)
316
+ // worker + bundled plugin roles + 2 global roles
317
+ expect(listRoles()).toHaveLength(1 + BUNDLED_ROLES.length + 2)
318
+ expect(() => resolveRole('worker')).not.toThrow()
319
+ expect(() => resolveRole('planner')).not.toThrow()
320
+ expect(() => resolveRole('global-role')).not.toThrow()
321
+ expect(() => resolveRole('another-global')).not.toThrow()
322
+ })
323
+ })
324
+
325
+ describe('precedence', () => {
326
+ let tempDir: string
327
+ let projectAgentsDir: string
328
+ let globalTempDir: string
329
+ let globalAgentsDir: string
330
+
331
+ beforeEach(async () => {
332
+ tempDir = await mkdtemp(
333
+ join(tmpdir(), 'pi-pm-subagents-test-precedence-cwd-'),
334
+ )
335
+ globalTempDir = await mkdtemp(
336
+ join(tmpdir(), 'pi-pm-subagents-test-precedence-global-'),
337
+ )
338
+ projectAgentsDir = join(tempDir, '.pi', 'agents')
339
+ globalAgentsDir = join(globalTempDir, 'agents')
340
+ await mkdir(projectAgentsDir, { recursive: true })
341
+ await mkdir(globalAgentsDir, { recursive: true })
342
+ process.env[MOCK_AGENT_DIR_VAR] = globalTempDir
343
+ })
344
+
345
+ afterEach(async () => {
346
+ await rm(tempDir, { recursive: true, force: true })
347
+ await rm(globalTempDir, { recursive: true, force: true })
348
+ })
349
+
350
+ it('project-level role overrides global role with the same name', async () => {
351
+ const globalPath = join(globalAgentsDir, 'override-me.md')
352
+ await writeFile(
353
+ globalPath,
354
+ `---
355
+ description: Global version.
356
+ ---
357
+ Global content.`,
358
+ )
359
+
360
+ const projectPath = join(projectAgentsDir, 'override-me.md')
361
+ await writeFile(
362
+ projectPath,
363
+ `---
364
+ description: Project version.
365
+ ---
366
+ Project content.`,
367
+ )
368
+
369
+ await loadRoles(tempDir)
370
+ const role = resolveRole('override-me')
371
+ expect(role.fm.description).toBe('Project version.')
372
+ expect(role.systemPrompt).toBe('Project content.')
373
+ })
374
+
375
+ it('overrides built-in worker when worker.md exists', async () => {
376
+ await writeFile(
377
+ join(projectAgentsDir, 'worker.md'),
378
+ `---
379
+ description: Custom worker.
380
+ ---
381
+ Custom worker prompt.`,
382
+ )
383
+
384
+ await loadRoles(tempDir)
385
+ const worker = resolveRole('worker')
386
+ expect(worker.fm.description).toBe('Custom worker.')
387
+ expect(worker.systemPrompt).toBe('Custom worker prompt.')
388
+ })
389
+
390
+ it('global role loads when no project override exists', async () => {
391
+ const globalPath = join(globalAgentsDir, 'global-only.md')
392
+ await writeFile(
393
+ globalPath,
394
+ `---
395
+ description: Only global.
396
+ ---
397
+ Only global content.`,
398
+ )
399
+
400
+ await loadRoles(tempDir)
401
+ const role = resolveRole('global-only')
402
+ expect(role.fm.description).toBe('Only global.')
403
+ expect(role.systemPrompt).toBe('Only global content.')
404
+ })
405
+
406
+ it('both project and global roles coexist when names differ', async () => {
407
+ await writeFile(
408
+ join(globalAgentsDir, 'global-only.md'),
409
+ 'Global content.',
410
+ )
411
+ await writeFile(
412
+ join(projectAgentsDir, 'project-only.md'),
413
+ 'Project content.',
414
+ )
415
+
416
+ await loadRoles(tempDir)
417
+ // worker + bundled plugin roles + 2 user roles
418
+ expect(listRoles()).toHaveLength(1 + BUNDLED_ROLES.length + 2)
419
+ expect(resolveRole('global-only').systemPrompt).toBe('Global content.')
420
+ expect(resolveRole('project-only').systemPrompt).toBe(
421
+ 'Project content.',
422
+ )
423
+ })
424
+
425
+ it('user-level role overrides bundled plugin role with the same name', async () => {
426
+ await writeFile(
427
+ join(globalAgentsDir, 'reviewer.md'),
428
+ `---
429
+ description: Global reviewer.
430
+ ---
431
+ Global reviewer prompt.`,
432
+ )
433
+
434
+ await loadRoles(tempDir)
435
+ const reviewer = resolveRole('reviewer')
436
+ expect(reviewer.fm.description).toBe('Global reviewer.')
437
+ expect(reviewer.systemPrompt).toBe('Global reviewer prompt.')
438
+ })
439
+
440
+ it('skips plugin roles when skipPluginAgents is true', async () => {
441
+ await loadRoles(tempDir, { skipPluginAgents: true })
442
+ expect(() => resolveRole('explorer')).toThrow()
443
+ expect(() => resolveRole('planner')).toThrow()
444
+ expect(listRoles()).toEqual(['worker'])
445
+ })
446
+
447
+ it('still loads user roles when skipPluginAgents is true', async () => {
448
+ await writeFile(join(globalAgentsDir, 'gamma.md'), 'Gamma content.')
449
+
450
+ await loadRoles(tempDir, { skipPluginAgents: true })
451
+ expect(new Set(listRoles())).toEqual(new Set(['worker', 'gamma']))
452
+ expect(resolveRole('gamma').systemPrompt).toBe('Gamma content.')
453
+ })
454
+
455
+ it('still loads project roles when skipPluginAgents is true', async () => {
456
+ await writeFile(
457
+ join(projectAgentsDir, 'project-role.md'),
458
+ 'Project content.',
459
+ )
460
+
461
+ await loadRoles(tempDir, { skipPluginAgents: true })
462
+ expect(new Set(listRoles())).toEqual(
463
+ new Set(['worker', 'project-role']),
464
+ )
465
+ expect(resolveRole('project-role').systemPrompt).toBe(
466
+ 'Project content.',
467
+ )
468
+ })
469
+ })
470
+ })
471
+
472
+ describe('resolveRole', () => {
473
+ it('returns the correct definition for a built-in role', async () => {
474
+ await loadRoles('/fake/cwd')
475
+ const worker = resolveRole('worker')
476
+ expect(worker.systemPrompt).toBe('')
477
+ })
478
+
479
+ it('returns the correct definition for a project role', async () => {
480
+ const tempDir = await mkdtemp(
481
+ join(tmpdir(), 'pi-pm-subagents-test-resolve-'),
482
+ )
483
+ try {
484
+ const agentsDir = join(tempDir, '.pi', 'agents')
485
+ await mkdir(agentsDir, { recursive: true })
486
+ await writeFile(
487
+ join(agentsDir, 'custom.md'),
488
+ `---
489
+ description: Custom role.
490
+ ---
491
+ Custom prompt.`,
492
+ )
493
+
494
+ await loadRoles(tempDir)
495
+ const role = resolveRole('custom')
496
+ expect(role.fm.description).toBe('Custom role.')
497
+ expect(role.systemPrompt).toBe('Custom prompt.')
498
+ } finally {
499
+ await rm(tempDir, { recursive: true, force: true })
500
+ }
501
+ })
502
+
503
+ it('throws for unknown role name', async () => {
504
+ await loadRoles('/fake/cwd')
505
+ expect(() => resolveRole('unknown-role')).toThrow(
506
+ 'Role "unknown-role" not found.',
507
+ )
508
+ })
509
+
510
+ it('throws error message includes available roles', async () => {
511
+ await loadRoles('/fake/cwd')
512
+ expect(() => resolveRole('unknown')).toThrow(/Available roles/)
513
+ })
514
+
515
+ it('throws when roles not loaded', async () => {
516
+ clearRoles()
517
+ expect(() => resolveRole('worker')).toThrow(
518
+ 'Roles not loaded. Call loadRoles() first.',
519
+ )
520
+ })
521
+ })
522
+
523
+ describe('listRoles', () => {
524
+ it('lists built-in roles first in stable order', async () => {
525
+ await loadRoles('/fake/cwd')
526
+ const names = listRoles()
527
+ expect(names[0]).toBe('worker')
528
+ expect(new Set(names)).toEqual(new Set(['worker', ...BUNDLED_ROLES]))
529
+ })
530
+
531
+ it('lists project roles after built-ins', async () => {
532
+ const tempDir = await mkdtemp(
533
+ join(tmpdir(), 'pi-pm-subagents-test-list-'),
534
+ )
535
+ try {
536
+ const agentsDir = join(tempDir, '.pi', 'agents')
537
+ await mkdir(agentsDir, { recursive: true })
538
+ await writeFile(join(agentsDir, 'zeta.md'), 'Zeta content.')
539
+ await writeFile(join(agentsDir, 'alpha.md'), 'Alpha content.')
540
+ await writeFile(join(agentsDir, 'beta.md'), 'Beta content.')
541
+
542
+ await loadRoles(tempDir)
543
+ const names = listRoles()
544
+ expect(names[0]).toBe('worker')
545
+ expect(new Set(names)).toEqual(
546
+ new Set([...BUNDLED_ROLES, 'worker', 'alpha', 'beta', 'zeta']),
547
+ )
548
+ } finally {
549
+ await rm(tempDir, { recursive: true, force: true })
550
+ }
551
+ })
552
+
553
+ it('includes global roles', async () => {
554
+ const globalTempDir = await mkdtemp(
555
+ join(tmpdir(), 'pi-pm-subagents-test-list-global-'),
556
+ )
557
+ const globalAgentsDir = join(globalTempDir, 'agents')
558
+ await mkdir(globalAgentsDir, { recursive: true })
559
+ process.env[MOCK_AGENT_DIR_VAR] = globalTempDir
560
+
561
+ try {
562
+ await writeFile(join(globalAgentsDir, 'gamma.md'), 'Gamma content.')
563
+ await writeFile(join(globalAgentsDir, 'delta.md'), 'Delta content.')
564
+
565
+ await loadRoles('/fake/cwd')
566
+ const names = listRoles()
567
+ expect(names[0]).toBe('worker')
568
+ expect(new Set(names)).toEqual(
569
+ new Set([...BUNDLED_ROLES, 'worker', 'delta', 'gamma']),
570
+ )
571
+ } finally {
572
+ await rm(globalTempDir, { recursive: true, force: true })
573
+ process.env[MOCK_AGENT_DIR_VAR] = originalAgentDir
574
+ }
575
+ })
576
+
577
+ it('includes project and global roles', async () => {
578
+ const tempDir = await mkdtemp(
579
+ join(tmpdir(), 'pi-pm-subagents-test-list-override-cwd-'),
580
+ )
581
+ const globalTempDir = await mkdtemp(
582
+ join(tmpdir(), 'pi-pm-subagents-test-list-override-global-'),
583
+ )
584
+ const globalAgentsDir = join(globalTempDir, 'agents')
585
+ await mkdir(globalAgentsDir, { recursive: true })
586
+ process.env[MOCK_AGENT_DIR_VAR] = globalTempDir
587
+
588
+ try {
589
+ await writeFile(
590
+ join(globalAgentsDir, 'omega.md'),
591
+ 'Global omega content.',
592
+ )
593
+ await writeFile(
594
+ join(globalAgentsDir, 'alpha.md'),
595
+ 'Global alpha content.',
596
+ )
597
+
598
+ const agentsDir = join(tempDir, '.pi', 'agents')
599
+ await mkdir(agentsDir, { recursive: true })
600
+ await writeFile(join(agentsDir, 'omega.md'), 'Project omega content.')
601
+ await writeFile(join(agentsDir, 'beta.md'), 'Project beta content.')
602
+
603
+ await loadRoles(tempDir)
604
+ const names = listRoles()
605
+ expect(names[0]).toBe('worker')
606
+ expect(new Set(names)).toEqual(
607
+ new Set([...BUNDLED_ROLES, 'worker', 'alpha', 'beta', 'omega']),
608
+ )
609
+ } finally {
610
+ await rm(tempDir, { recursive: true, force: true })
611
+ await rm(globalTempDir, { recursive: true, force: true })
612
+ process.env[MOCK_AGENT_DIR_VAR] = originalAgentDir
613
+ }
614
+ })
615
+
616
+ it('returns empty array when roles not loaded', () => {
617
+ clearRoles()
618
+ expect(listRoles()).toEqual([])
619
+ })
620
+ })
621
+
622
+ describe('rolesDescription', () => {
623
+ it('returns empty string when no roles loaded', () => {
624
+ clearRoles()
625
+ expect(rolesDescription()).toBe('')
626
+ })
627
+
628
+ it('lists built-in roles without descriptions', async () => {
629
+ await loadRoles('/fake/cwd')
630
+ const description = rolesDescription()
631
+ expect(description).toContain(' - worker')
632
+ })
633
+
634
+ it('includes role descriptions and tools when available', async () => {
635
+ const tempDir = await mkdtemp(
636
+ join(tmpdir(), 'pi-pm-subagents-test-desc-tools-'),
637
+ )
638
+ try {
639
+ const agentsDir = join(tempDir, '.pi', 'agents')
640
+ await mkdir(agentsDir, { recursive: true })
641
+ await writeFile(
642
+ join(agentsDir, 'custom.md'),
643
+ `---
644
+ description: A custom role for testing.
645
+ tools:
646
+ - read
647
+ - grep
648
+ ---
649
+ Custom prompt.`,
650
+ )
651
+
652
+ await loadRoles(tempDir)
653
+ const description = rolesDescription()
654
+ expect(description).toContain(
655
+ ' - custom: A custom role for testing.; tools: read, grep',
656
+ )
657
+ } finally {
658
+ await rm(tempDir, { recursive: true, force: true })
659
+ }
660
+ })
661
+
662
+ it('includes tools-only when no description', async () => {
663
+ const tempDir = await mkdtemp(
664
+ join(tmpdir(), 'pi-pm-subagents-test-tools-only-'),
665
+ )
666
+ try {
667
+ const agentsDir = join(tempDir, '.pi', 'agents')
668
+ await mkdir(agentsDir, { recursive: true })
669
+ await writeFile(
670
+ join(agentsDir, 'toolrole.md'),
671
+ `---
672
+ tools:
673
+ - read
674
+ - find
675
+ ---
676
+ Tool role prompt.`,
677
+ )
678
+
679
+ await loadRoles(tempDir)
680
+ const description = rolesDescription()
681
+ expect(description).toContain(' - toolrole: tools: read, find')
682
+ } finally {
683
+ await rm(tempDir, { recursive: true, force: true })
684
+ }
685
+ })
686
+
687
+ it('shows effective tools for roles with extraTools/removeTools', async () => {
688
+ await loadRoles('/fake/cwd')
689
+ const description = rolesDescription(['read', 'write', 'edit', 'bash'])
690
+ const plannerLine = description
691
+ .split('\n')
692
+ .find((line) => line.startsWith(' - planner:'))
693
+ expect(plannerLine).toBeDefined()
694
+ expect(plannerLine).toContain('tools: read, bash_readonly')
695
+ })
696
+
697
+ it('combines built-in and custom roles', async () => {
698
+ const tempDir = await mkdtemp(
699
+ join(tmpdir(), 'pi-pm-subagents-test-combined-'),
700
+ )
701
+ try {
702
+ const agentsDir = join(tempDir, '.pi', 'agents')
703
+ await mkdir(agentsDir, { recursive: true })
704
+ await writeFile(
705
+ join(agentsDir, 'alpha.md'),
706
+ `---
707
+ description: Alpha role description.
708
+ ---
709
+ Alpha prompt.`,
710
+ )
711
+ await writeFile(join(agentsDir, 'beta.md'), 'Beta without description.')
712
+
713
+ await loadRoles(tempDir)
714
+ const description = rolesDescription()
715
+
716
+ expect(description).toContain(' - worker')
717
+ expect(description).toContain(' - alpha: Alpha role description.')
718
+ expect(description).toContain(' - beta')
719
+ } finally {
720
+ await rm(tempDir, { recursive: true, force: true })
721
+ }
722
+ })
723
+ })
724
+ })