@doingdev/opencode-claude-manager-plugin 0.1.44 → 0.1.47

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 (53) hide show
  1. package/README.md +29 -31
  2. package/dist/index.d.ts +1 -1
  3. package/dist/manager/persistent-manager.d.ts +3 -23
  4. package/dist/manager/persistent-manager.js +2 -95
  5. package/dist/manager/team-orchestrator.d.ts +50 -0
  6. package/dist/manager/team-orchestrator.js +360 -0
  7. package/dist/plugin/agent-hierarchy.d.ts +12 -34
  8. package/dist/plugin/agent-hierarchy.js +36 -129
  9. package/dist/plugin/claude-manager.plugin.js +190 -445
  10. package/dist/plugin/service-factory.d.ts +18 -3
  11. package/dist/plugin/service-factory.js +32 -1
  12. package/dist/prompts/registry.d.ts +1 -10
  13. package/dist/prompts/registry.js +42 -270
  14. package/dist/src/claude/claude-agent-sdk-adapter.js +2 -1
  15. package/dist/src/claude/session-live-tailer.js +2 -2
  16. package/dist/src/index.d.ts +1 -1
  17. package/dist/src/manager/git-operations.d.ts +10 -1
  18. package/dist/src/manager/git-operations.js +18 -3
  19. package/dist/src/manager/persistent-manager.d.ts +18 -6
  20. package/dist/src/manager/persistent-manager.js +19 -13
  21. package/dist/src/manager/session-controller.d.ts +7 -10
  22. package/dist/src/manager/session-controller.js +12 -62
  23. package/dist/src/manager/team-orchestrator.d.ts +50 -0
  24. package/dist/src/manager/team-orchestrator.js +360 -0
  25. package/dist/src/plugin/agent-hierarchy.d.ts +12 -26
  26. package/dist/src/plugin/agent-hierarchy.js +36 -99
  27. package/dist/src/plugin/claude-manager.plugin.js +214 -393
  28. package/dist/src/plugin/service-factory.d.ts +18 -3
  29. package/dist/src/plugin/service-factory.js +33 -9
  30. package/dist/src/prompts/registry.d.ts +1 -10
  31. package/dist/src/prompts/registry.js +41 -246
  32. package/dist/src/state/team-state-store.d.ts +14 -0
  33. package/dist/src/state/team-state-store.js +85 -0
  34. package/dist/src/team/roster.d.ts +5 -0
  35. package/dist/src/team/roster.js +38 -0
  36. package/dist/src/types/contracts.d.ts +55 -13
  37. package/dist/src/types/contracts.js +1 -1
  38. package/dist/state/team-state-store.d.ts +14 -0
  39. package/dist/state/team-state-store.js +85 -0
  40. package/dist/team/roster.d.ts +5 -0
  41. package/dist/team/roster.js +38 -0
  42. package/dist/test/claude-manager.plugin.test.js +55 -280
  43. package/dist/test/git-operations.test.js +65 -1
  44. package/dist/test/persistent-manager.test.js +3 -3
  45. package/dist/test/prompt-registry.test.js +32 -252
  46. package/dist/test/session-controller.test.js +27 -27
  47. package/dist/test/team-orchestrator.test.d.ts +1 -0
  48. package/dist/test/team-orchestrator.test.js +146 -0
  49. package/dist/test/team-state-store.test.d.ts +1 -0
  50. package/dist/test/team-state-store.test.js +54 -0
  51. package/dist/types/contracts.d.ts +50 -23
  52. package/dist/types/contracts.js +1 -1
  53. package/package.json +1 -1
@@ -0,0 +1,146 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { TeamOrchestrator } from '../src/manager/team-orchestrator.js';
6
+ import { TeamStateStore } from '../src/state/team-state-store.js';
7
+ describe('TeamOrchestrator', () => {
8
+ let tempRoot;
9
+ afterEach(async () => {
10
+ if (tempRoot) {
11
+ await rm(tempRoot, { recursive: true, force: true });
12
+ }
13
+ });
14
+ it('dispatches work to a named engineer and persists the Claude session', async () => {
15
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
16
+ const runTask = vi
17
+ .fn()
18
+ .mockResolvedValueOnce({
19
+ sessionId: 'ses_tom',
20
+ events: [{ type: 'result', text: 'done', turns: 1, totalCostUsd: 0.02 }],
21
+ finalText: 'Done.',
22
+ turns: 1,
23
+ totalCostUsd: 0.02,
24
+ inputTokens: 1000,
25
+ outputTokens: 200,
26
+ contextWindowSize: 200_000,
27
+ })
28
+ .mockResolvedValueOnce({
29
+ sessionId: 'ses_tom',
30
+ events: [{ type: 'result', text: 'done again', turns: 2, totalCostUsd: 0.03 }],
31
+ finalText: 'Done again.',
32
+ turns: 2,
33
+ totalCostUsd: 0.03,
34
+ inputTokens: 2000,
35
+ outputTokens: 300,
36
+ contextWindowSize: 200_000,
37
+ });
38
+ const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', []);
39
+ const first = await orchestrator.dispatchEngineer({
40
+ teamId: 'team-1',
41
+ cwd: tempRoot,
42
+ engineer: 'Tom',
43
+ mode: 'explore',
44
+ message: 'Investigate the auth flow',
45
+ });
46
+ const second = await orchestrator.dispatchEngineer({
47
+ teamId: 'team-1',
48
+ cwd: tempRoot,
49
+ engineer: 'Tom',
50
+ mode: 'implement',
51
+ message: 'Implement the chosen fix',
52
+ });
53
+ expect(first.sessionId).toBe('ses_tom');
54
+ expect(second.sessionId).toBe('ses_tom');
55
+ expect(runTask.mock.calls[0]?.[0]).toMatchObject({
56
+ systemPrompt: expect.stringContaining('Assigned engineer: Tom.'),
57
+ resumeSessionId: undefined,
58
+ permissionMode: 'plan',
59
+ });
60
+ expect(runTask.mock.calls[1]?.[0]).toMatchObject({
61
+ systemPrompt: undefined,
62
+ resumeSessionId: 'ses_tom',
63
+ permissionMode: 'acceptEdits',
64
+ });
65
+ const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
66
+ expect(team.engineers.find((engineer) => engineer.name === 'Tom')).toMatchObject({
67
+ claudeSessionId: 'ses_tom',
68
+ busy: false,
69
+ lastMode: 'implement',
70
+ });
71
+ });
72
+ it('rejects work when the same engineer is already busy', async () => {
73
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
74
+ const store = new TeamStateStore('.state');
75
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', []);
76
+ const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
77
+ await store.saveTeam({
78
+ ...team,
79
+ engineers: team.engineers.map((engineer) => engineer.name === 'Tom'
80
+ ? {
81
+ ...engineer,
82
+ busy: true,
83
+ }
84
+ : engineer),
85
+ });
86
+ await expect(orchestrator.dispatchEngineer({
87
+ teamId: 'team-1',
88
+ cwd: tempRoot,
89
+ engineer: 'Tom',
90
+ mode: 'explore',
91
+ message: 'Investigate again',
92
+ })).rejects.toThrow('Tom is already working on another assignment.');
93
+ });
94
+ it('creates two drafts and synthesizes them into one plan', async () => {
95
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
96
+ const runTask = vi
97
+ .fn()
98
+ .mockResolvedValueOnce({
99
+ sessionId: 'ses_tom',
100
+ events: [],
101
+ finalText: '## Objective\nLead plan',
102
+ })
103
+ .mockResolvedValueOnce({
104
+ sessionId: 'ses_maya',
105
+ events: [],
106
+ finalText: '## Objective\nChallenger plan',
107
+ })
108
+ .mockResolvedValueOnce({
109
+ sessionId: undefined,
110
+ events: [],
111
+ finalText: '## Synthesis\nCombined plan\n## Recommended Question\nShould we migrate now?\n## Recommended Answer\nNo, defer it.',
112
+ });
113
+ const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', []);
114
+ const result = await orchestrator.planWithTeam({
115
+ teamId: 'team-1',
116
+ cwd: tempRoot,
117
+ request: 'Plan the billing refactor',
118
+ leadEngineer: 'Tom',
119
+ challengerEngineer: 'Maya',
120
+ });
121
+ expect(result.drafts).toHaveLength(2);
122
+ expect(result.synthesis).toBe('Combined plan');
123
+ expect(result.recommendedQuestion).toBe('Should we migrate now?');
124
+ expect(result.recommendedAnswer).toBe('No, defer it.');
125
+ expect(runTask).toHaveBeenCalledTimes(3);
126
+ });
127
+ it('persists wrapper session memory for an engineer', async () => {
128
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
129
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', []);
130
+ await orchestrator.recordWrapperSession(tempRoot, 'team-1', 'Tom', 'wrapper-tom');
131
+ await orchestrator.recordWrapperExchange(tempRoot, 'team-1', 'Tom', 'wrapper-tom', 'explore', 'Investigate the auth flow and compare approaches', 'The auth flow uses one shared validator and the cookie refresh path is the main risk.');
132
+ const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
133
+ expect(team.engineers.find((engineer) => engineer.name === 'Tom')).toMatchObject({
134
+ wrapperSessionId: 'wrapper-tom',
135
+ lastMode: 'explore',
136
+ });
137
+ const wrapperContext = await orchestrator.getWrapperSystemContext(tempRoot, 'team-1', 'Tom');
138
+ expect(wrapperContext).toContain('Persistent wrapper memory for Tom');
139
+ expect(wrapperContext).toContain('assignment [explore]');
140
+ expect(wrapperContext).toContain('result [explore]');
141
+ await expect(orchestrator.findTeamByWrapperSession(tempRoot, 'wrapper-tom')).resolves.toEqual({
142
+ teamId: 'team-1',
143
+ engineer: 'Tom',
144
+ });
145
+ });
146
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { TeamStateStore } from '../src/state/team-state-store.js';
6
+ import { createEmptyTeamRecord } from '../src/team/roster.js';
7
+ describe('TeamStateStore', () => {
8
+ let tempRoot;
9
+ afterEach(async () => {
10
+ if (tempRoot) {
11
+ await rm(tempRoot, { recursive: true, force: true });
12
+ }
13
+ });
14
+ it('saves and reads a team record', async () => {
15
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
16
+ const store = new TeamStateStore('.state');
17
+ const team = createEmptyTeamRecord('team-1', tempRoot);
18
+ await store.saveTeam(team);
19
+ await expect(store.getTeam(tempRoot, 'team-1')).resolves.toEqual(team);
20
+ });
21
+ it('updates one engineer inside a team record', async () => {
22
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
23
+ const store = new TeamStateStore('.state');
24
+ const team = createEmptyTeamRecord('team-1', tempRoot);
25
+ await store.saveTeam(team);
26
+ const updated = await store.updateTeam(tempRoot, 'team-1', (existing) => ({
27
+ ...existing,
28
+ updatedAt: '2026-01-01T00:00:00.000Z',
29
+ engineers: existing.engineers.map((engineer) => engineer.name === 'Tom'
30
+ ? {
31
+ ...engineer,
32
+ claudeSessionId: 'ses_tom',
33
+ lastTaskSummary: 'Plan the feature',
34
+ }
35
+ : engineer),
36
+ }));
37
+ expect(updated.engineers.find((engineer) => engineer.name === 'Tom')?.claudeSessionId).toBe('ses_tom');
38
+ expect(updated.engineers.find((engineer) => engineer.name === 'John')?.claudeSessionId).toBe(null);
39
+ });
40
+ it('lists teams newest first', async () => {
41
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-store-'));
42
+ const store = new TeamStateStore('.state');
43
+ const older = createEmptyTeamRecord('older', tempRoot);
44
+ older.updatedAt = '2026-01-01T00:00:00.000Z';
45
+ const newer = createEmptyTeamRecord('newer', tempRoot);
46
+ newer.updatedAt = '2026-01-02T00:00:00.000Z';
47
+ await store.saveTeam(older);
48
+ await store.saveTeam(newer);
49
+ await expect(store.listTeams(tempRoot)).resolves.toMatchObject([
50
+ { id: 'newer' },
51
+ { id: 'older' },
52
+ ]);
53
+ });
54
+ });
@@ -1,8 +1,6 @@
1
1
  export interface ManagerPromptRegistry {
2
2
  ctoSystemPrompt: string;
3
- engineerExplorePrompt: string;
4
- engineerImplementPrompt: string;
5
- engineerVerifyPrompt: string;
3
+ engineerAgentPrompt: string;
6
4
  engineerSessionPrompt: string;
7
5
  modePrefixes: {
8
6
  plan: string;
@@ -15,6 +13,15 @@ export interface ManagerPromptRegistry {
15
13
  };
16
14
  }
17
15
  export type SessionMode = 'plan' | 'free';
16
+ export declare const DEFAULT_ENGINEER_NAMES: readonly ["Tom", "John", "Maya", "Sara", "Alex"];
17
+ export type EngineerName = (typeof DEFAULT_ENGINEER_NAMES)[number];
18
+ export type EngineerWorkMode = 'explore' | 'implement' | 'verify';
19
+ export interface WrapperHistoryEntry {
20
+ timestamp: string;
21
+ type: 'assignment' | 'result';
22
+ mode?: EngineerWorkMode;
23
+ text: string;
24
+ }
18
25
  export interface ClaudeCommandMetadata {
19
26
  name: string;
20
27
  description: string;
@@ -98,29 +105,49 @@ export interface SessionContextSnapshot {
98
105
  warningLevel: ContextWarningLevel;
99
106
  compactionCount: number;
100
107
  }
101
- export type AgentSlotType = 'implement' | 'verify' | 'explore';
102
- export interface RunningTask {
103
- slotType: AgentSlotType;
104
- task: string;
105
- startedAt: string;
108
+ export interface TeamEngineerRecord {
109
+ name: EngineerName;
110
+ wrapperSessionId: string | null;
111
+ claudeSessionId: string | null;
112
+ busy: boolean;
113
+ lastMode: EngineerWorkMode | null;
114
+ lastTaskSummary: string | null;
115
+ lastUsedAt: string | null;
116
+ wrapperHistory: WrapperHistoryEntry[];
117
+ context: SessionContextSnapshot;
106
118
  }
107
- export interface SlotStatus {
108
- implement: {
109
- used: number;
110
- max: number;
111
- };
112
- verify: {
113
- used: number;
114
- max: number;
115
- };
116
- explore: {
117
- used: number;
118
- };
119
+ export interface TeamRecord {
120
+ id: string;
121
+ cwd: string;
122
+ createdAt: string;
123
+ updatedAt: string;
124
+ engineers: TeamEngineerRecord[];
119
125
  }
120
- export interface ManagerStatus {
126
+ export interface EngineerTaskResult {
127
+ teamId: string;
128
+ engineer: EngineerName;
129
+ mode: EngineerWorkMode;
130
+ sessionId?: string;
131
+ finalText: string;
132
+ turns?: number;
133
+ totalCostUsd?: number;
134
+ inputTokens?: number;
135
+ outputTokens?: number;
136
+ contextWindowSize?: number;
121
137
  context: SessionContextSnapshot;
122
- slotStatus: SlotStatus;
123
- runningTasks: RunningTask[];
138
+ }
139
+ export interface PlanDraft extends EngineerTaskResult {
140
+ request: string;
141
+ }
142
+ export interface SynthesizedPlanResult {
143
+ teamId: string;
144
+ request: string;
145
+ leadEngineer: EngineerName;
146
+ challengerEngineer: EngineerName;
147
+ drafts: [PlanDraft, PlanDraft];
148
+ synthesis: string;
149
+ recommendedQuestion: string | null;
150
+ recommendedAnswer: string | null;
124
151
  }
125
152
  export interface GitDiffResult {
126
153
  hasDiff: boolean;
@@ -1 +1 @@
1
- export {};
1
+ export const DEFAULT_ENGINEER_NAMES = ['Tom', 'John', 'Maya', 'Sara', 'Alex'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doingdev/opencode-claude-manager-plugin",
3
- "version": "0.1.44",
3
+ "version": "0.1.47",
4
4
  "description": "OpenCode plugin that orchestrates Claude Code sessions.",
5
5
  "keywords": [
6
6
  "opencode",