@doingdev/opencode-claude-manager-plugin 0.1.57 → 0.1.59

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 (59) hide show
  1. package/dist/manager/team-orchestrator.d.ts +3 -2
  2. package/dist/manager/team-orchestrator.js +32 -9
  3. package/dist/plugin/agent-hierarchy.d.ts +1 -54
  4. package/dist/plugin/agent-hierarchy.js +2 -123
  5. package/dist/plugin/agents/browser-qa.d.ts +14 -0
  6. package/dist/plugin/agents/browser-qa.js +27 -0
  7. package/dist/plugin/agents/common.d.ts +37 -0
  8. package/dist/plugin/agents/common.js +59 -0
  9. package/dist/plugin/agents/cto.d.ts +9 -0
  10. package/dist/plugin/agents/cto.js +39 -0
  11. package/dist/plugin/agents/engineers.d.ts +9 -0
  12. package/dist/plugin/agents/engineers.js +11 -0
  13. package/dist/plugin/agents/index.d.ts +6 -0
  14. package/dist/plugin/agents/index.js +5 -0
  15. package/dist/plugin/agents/team-planner.d.ts +10 -0
  16. package/dist/plugin/agents/team-planner.js +23 -0
  17. package/dist/plugin/claude-manager.plugin.js +97 -47
  18. package/dist/plugin/service-factory.d.ts +8 -7
  19. package/dist/plugin/service-factory.js +18 -19
  20. package/dist/prompts/registry.js +37 -2
  21. package/dist/src/manager/team-orchestrator.d.ts +3 -2
  22. package/dist/src/manager/team-orchestrator.js +32 -9
  23. package/dist/src/plugin/agent-hierarchy.d.ts +1 -54
  24. package/dist/src/plugin/agent-hierarchy.js +2 -123
  25. package/dist/src/plugin/agents/browser-qa.d.ts +14 -0
  26. package/dist/src/plugin/agents/browser-qa.js +27 -0
  27. package/dist/src/plugin/agents/common.d.ts +37 -0
  28. package/dist/src/plugin/agents/common.js +59 -0
  29. package/dist/src/plugin/agents/cto.d.ts +9 -0
  30. package/dist/src/plugin/agents/cto.js +39 -0
  31. package/dist/src/plugin/agents/engineers.d.ts +9 -0
  32. package/dist/src/plugin/agents/engineers.js +11 -0
  33. package/dist/src/plugin/agents/index.d.ts +6 -0
  34. package/dist/src/plugin/agents/index.js +5 -0
  35. package/dist/src/plugin/agents/team-planner.d.ts +10 -0
  36. package/dist/src/plugin/agents/team-planner.js +23 -0
  37. package/dist/src/plugin/claude-manager.plugin.js +97 -47
  38. package/dist/src/plugin/service-factory.d.ts +8 -7
  39. package/dist/src/plugin/service-factory.js +18 -19
  40. package/dist/src/prompts/registry.js +37 -2
  41. package/dist/src/state/team-state-store.d.ts +0 -3
  42. package/dist/src/state/team-state-store.js +0 -22
  43. package/dist/src/team/roster.d.ts +3 -2
  44. package/dist/src/team/roster.js +2 -1
  45. package/dist/src/types/contracts.d.ts +25 -1
  46. package/dist/src/types/contracts.js +2 -1
  47. package/dist/state/team-state-store.d.ts +0 -3
  48. package/dist/state/team-state-store.js +0 -22
  49. package/dist/team/roster.d.ts +3 -2
  50. package/dist/team/roster.js +2 -1
  51. package/dist/test/claude-manager.plugin.test.js +60 -0
  52. package/dist/test/cto-active-team.test.js +176 -29
  53. package/dist/test/prompt-registry.test.js +15 -0
  54. package/dist/test/report-claude-event.test.js +60 -15
  55. package/dist/test/team-orchestrator.test.js +47 -8
  56. package/dist/test/team-state-store.test.js +0 -18
  57. package/dist/types/contracts.d.ts +25 -1
  58. package/dist/types/contracts.js +2 -1
  59. package/package.json +1 -1
@@ -2,13 +2,47 @@ import { tool } from '@opencode-ai/plugin';
2
2
  import { managerPromptRegistry } from '../prompts/registry.js';
3
3
  import { isEngineerName } from '../team/roster.js';
4
4
  import { TeamOrchestrator, createActionableError, getFailureGuidanceText, } from '../manager/team-orchestrator.js';
5
- import { AGENT_CTO, AGENT_TEAM_PLANNER, ENGINEER_AGENT_IDS, ENGINEER_AGENT_NAMES, buildCtoAgentConfig, buildEngineerAgentConfig, buildTeamPlannerAgentConfig, denyRestrictedToolsGlobally, } from './agent-hierarchy.js';
6
- import { getActiveTeamSession, getOrCreatePluginServices, getPersistedActiveTeam, getWrapperSessionMapping, setActiveTeamSession, setPersistedActiveTeam, setWrapperSessionMapping, } from './service-factory.js';
5
+ import { AGENT_BROWSER_QA, AGENT_CTO, AGENT_TEAM_PLANNER, buildBrowserQaAgentConfig, buildCtoAgentConfig, buildEngineerAgentConfig, buildTeamPlannerAgentConfig, denyRestrictedToolsGlobally, ENGINEER_AGENT_IDS, ENGINEER_AGENT_NAMES, } from './agents/index.js';
6
+ import { getOrCreatePluginServices, getParentSessionId, getSessionTeam, getWrapperSessionMapping, registerParentSession, registerSessionTeam, setWrapperSessionMapping, } from './service-factory.js';
7
7
  const MODEL_ENUM = ['claude-opus-4-6', 'claude-sonnet-4-6'];
8
8
  const MODE_ENUM = ['explore', 'implement', 'verify'];
9
- export const ClaudeManagerPlugin = async ({ worktree }) => {
9
+ export const ClaudeManagerPlugin = async ({ worktree, client }) => {
10
10
  const services = getOrCreatePluginServices(worktree);
11
11
  await services.approvalManager.loadPersistedPolicy();
12
+ /**
13
+ * Resolves the team ID for a brand-new engineer wrapper session.
14
+ *
15
+ * 1. Walk the cached parentID chain (populated by session.created events).
16
+ * 2. On a cache miss, attempt a live client.session.get() lookup and cache
17
+ * whatever parentID the SDK returns.
18
+ * 3. Fall back to the orphan sentinel (sessionID itself) only when both
19
+ * cache and live lookup come up empty.
20
+ */
21
+ async function resolveTeamId(sessionID) {
22
+ let current = sessionID;
23
+ const seen = new Set();
24
+ while (current && !seen.has(current)) {
25
+ seen.add(current);
26
+ const team = getSessionTeam(current);
27
+ if (team !== undefined)
28
+ return team;
29
+ // Cache miss on this node's parent: try the live SDK.
30
+ if (client && !getParentSessionId(current)) {
31
+ try {
32
+ const result = await client.session.get({ path: { id: current } });
33
+ const parentID = result.data?.parentID;
34
+ if (parentID) {
35
+ registerParentSession(current, parentID);
36
+ }
37
+ }
38
+ catch {
39
+ // Network / auth failure — let the walk continue to orphan.
40
+ }
41
+ }
42
+ current = getParentSessionId(current);
43
+ }
44
+ return sessionID;
45
+ }
12
46
  return {
13
47
  config: async (config) => {
14
48
  config.agent ??= {};
@@ -16,21 +50,16 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
16
50
  denyRestrictedToolsGlobally(config.permission);
17
51
  config.agent[AGENT_CTO] ??= buildCtoAgentConfig(managerPromptRegistry);
18
52
  config.agent[AGENT_TEAM_PLANNER] ??= buildTeamPlannerAgentConfig(managerPromptRegistry);
53
+ config.agent[AGENT_BROWSER_QA] ??= buildBrowserQaAgentConfig(managerPromptRegistry);
19
54
  for (const engineer of ENGINEER_AGENT_NAMES) {
20
55
  config.agent[ENGINEER_AGENT_IDS[engineer]] ??= buildEngineerAgentConfig(managerPromptRegistry, engineer);
21
56
  }
22
57
  },
23
58
  'chat.message': async (input) => {
24
59
  if (input.agent === AGENT_CTO) {
25
- // Adopt the persisted active team if one exists, so a new CTO session
26
- // does not orphan previously created engineers and wrapper memory.
27
- const persistedTeamId = await getPersistedActiveTeam(worktree);
28
- const activeTeamId = persistedTeamId ?? input.sessionID;
29
- setActiveTeamSession(worktree, activeTeamId);
30
- if (!persistedTeamId) {
31
- // First CTO session for this worktree — persist this session as active team.
32
- await setPersistedActiveTeam(worktree, activeTeamId);
33
- }
60
+ // Each CTO session ID is its own team. The session ID is the durable
61
+ // identity: no worktree-global active-team state.
62
+ registerSessionTeam(input.sessionID, input.sessionID);
34
63
  return;
35
64
  }
36
65
  if (input.agent && isEngineerAgent(input.agent)) {
@@ -38,46 +67,65 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
38
67
  const existing = getWrapperSessionMapping(worktree, input.sessionID);
39
68
  const persisted = existing ??
40
69
  (await services.orchestrator.findTeamByWrapperSession(worktree, input.sessionID));
41
- const teamId = persisted?.teamId ?? (await resolveTeamId(worktree, input.sessionID));
70
+ const teamId = persisted?.teamId ?? (await resolveTeamId(input.sessionID));
42
71
  setWrapperSessionMapping(worktree, input.sessionID, {
43
72
  teamId,
44
- engineer,
73
+ workerName: engineer,
45
74
  });
46
75
  await services.orchestrator.recordWrapperSession(worktree, teamId, engineer, input.sessionID);
47
76
  }
48
77
  },
78
+ event: async ({ event: sdkEvent }) => {
79
+ if (sdkEvent.type === 'session.created') {
80
+ const session = sdkEvent.properties.info;
81
+ if (session.parentID) {
82
+ registerParentSession(session.id, session.parentID);
83
+ }
84
+ }
85
+ },
49
86
  'experimental.chat.system.transform': async (input, output) => {
50
87
  if (!input.sessionID) {
51
88
  return;
52
89
  }
53
- const existing = getWrapperSessionMapping(worktree, input.sessionID);
54
- const persisted = existing ??
55
- (await services.orchestrator.findTeamByWrapperSession(worktree, input.sessionID));
56
- if (!persisted) {
90
+ // Try in-memory mapping first
91
+ let mapping = getWrapperSessionMapping(worktree, input.sessionID);
92
+ // Fall back to persisted lookup if in-memory mapping is absent
93
+ if (!mapping) {
94
+ // Check if this is an engineer wrapper session
95
+ const engineerMatch = await services.orchestrator.findTeamByWrapperSession(worktree, input.sessionID);
96
+ if (engineerMatch) {
97
+ mapping = {
98
+ teamId: engineerMatch.teamId,
99
+ workerName: engineerMatch.engineer,
100
+ };
101
+ }
102
+ }
103
+ if (!mapping) {
57
104
  return;
58
105
  }
59
- const wrapperContext = await services.orchestrator.getWrapperSystemContext(worktree, persisted.teamId, persisted.engineer);
106
+ const wrapperContext = await services.orchestrator.getWrapperSystemContext(worktree, mapping.teamId, mapping.workerName);
60
107
  if (wrapperContext) {
61
108
  output.system.push(wrapperContext);
62
109
  }
63
110
  },
64
111
  tool: {
65
112
  claude: tool({
66
- description: "Run work through this named engineer's persistent Claude Code session. The session remembers prior turns for this engineer.",
113
+ description: "Run work through a named engineer's persistent Claude Code session. Engineers include general developers (Tom, John, Maya, Sara, Alex) and specialists like browser-qa. The session remembers prior turns.",
67
114
  args: {
68
115
  mode: tool.schema.enum(MODE_ENUM),
69
116
  message: tool.schema.string().min(1),
70
117
  model: tool.schema.enum(MODEL_ENUM).optional(),
71
118
  },
72
119
  async execute(args, context) {
120
+ // Handle engineer agents (includes BrowserQA)
73
121
  const engineer = engineerFromAgent(context.agent);
74
122
  const existing = getWrapperSessionMapping(context.worktree, context.sessionID);
75
123
  const persisted = existing ??
76
124
  (await services.orchestrator.findTeamByWrapperSession(context.worktree, context.sessionID));
77
- const teamId = persisted?.teamId ?? (await resolveTeamId(context.worktree, context.sessionID));
125
+ const teamId = persisted?.teamId ?? (await resolveTeamId(context.sessionID));
78
126
  setWrapperSessionMapping(context.worktree, context.sessionID, {
79
127
  teamId,
80
- engineer,
128
+ workerName: engineer,
81
129
  });
82
130
  await services.orchestrator.recordWrapperSession(context.worktree, teamId, engineer, context.sessionID);
83
131
  const result = await runEngineerAssignment({
@@ -87,6 +135,15 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
87
135
  message: args.message,
88
136
  model: args.model,
89
137
  }, context);
138
+ const capabilities = services.workerCapabilities[engineer];
139
+ if (capabilities?.isRuntimeUnavailableResponse?.(result.finalText)) {
140
+ const lines = result.finalText.split('\n');
141
+ const unavailableLine = lines[0] ?? 'Playwright unavailable (reason unknown)';
142
+ context.metadata({
143
+ title: capabilities.runtimeUnavailableTitle ?? '❌ Playwright unavailable',
144
+ metadata: { unavailable: unavailableLine },
145
+ });
146
+ }
90
147
  return result.finalText;
91
148
  },
92
149
  }),
@@ -96,7 +153,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
96
153
  teamId: tool.schema.string().optional(),
97
154
  },
98
155
  async execute(args, context) {
99
- const teamId = args.teamId ?? getActiveTeamSession(context.worktree) ?? context.sessionID;
156
+ const teamId = args.teamId ?? context.sessionID;
100
157
  annotateToolRun(context, 'Reading team status', {
101
158
  teamId,
102
159
  });
@@ -113,7 +170,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
113
170
  model: tool.schema.enum(MODEL_ENUM).optional(),
114
171
  },
115
172
  async execute(args, context) {
116
- const teamId = getActiveTeamSession(context.worktree) ?? context.sessionID;
173
+ const teamId = context.sessionID;
117
174
  // Pre-determine engineers for event labeling (using orchestrator selection logic)
118
175
  const { lead, challenger } = await services.orchestrator.selectPlanEngineers(context.worktree, teamId, args.leadEngineer, args.challengerEngineer);
119
176
  annotateToolRun(context, 'Running dual-engineer plan synthesis', {
@@ -152,12 +209,12 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
152
209
  reset_engineer: tool({
153
210
  description: 'Reset a stuck or corrupted engineer. Clears the busy flag. Optionally clears the Claude session (starts fresh) and/or wrapper history.',
154
211
  args: {
155
- engineer: tool.schema.enum(['Tom', 'John', 'Maya', 'Sara', 'Alex']),
212
+ engineer: tool.schema.enum(['Tom', 'John', 'Maya', 'Sara', 'Alex', 'BrowserQA']),
156
213
  clearSession: tool.schema.boolean().optional(),
157
214
  clearHistory: tool.schema.boolean().optional(),
158
215
  },
159
216
  async execute(args, context) {
160
- const teamId = getActiveTeamSession(context.worktree) ?? context.sessionID;
217
+ const teamId = context.sessionID;
161
218
  annotateToolRun(context, `Resetting ${args.engineer}`, {
162
219
  teamId,
163
220
  clearSession: args.clearSession,
@@ -435,14 +492,6 @@ export function isEngineerAgent(agentId) {
435
492
  const normalized = normalizeAgentId(agentId);
436
493
  return Object.values(ENGINEER_AGENT_IDS).some((id) => id === normalized);
437
494
  }
438
- /**
439
- * Resolves the team ID for an engineer session.
440
- * Reads the persisted active team first (survives process restarts), then
441
- * falls back to the in-memory registry, then to the raw session ID as a last resort.
442
- */
443
- async function resolveTeamId(worktree, sessionID) {
444
- return (await getPersistedActiveTeam(worktree)) ?? getActiveTeamSession(worktree) ?? sessionID;
445
- }
446
495
  function formatToolDescription(toolName, toolArgs) {
447
496
  if (!toolArgs || typeof toolArgs !== 'object')
448
497
  return undefined;
@@ -497,12 +546,13 @@ function formatToolDescription(toolName, toolArgs) {
497
546
  return undefined;
498
547
  }
499
548
  }
500
- function reportClaudeEvent(context, engineer, event) {
549
+ function reportClaudeEvent(context, workerName, event) {
550
+ const baseMetadata = { workerName, engineer: workerName };
501
551
  if (event.type === 'error') {
502
552
  context.metadata({
503
- title: `❌ ${engineer} hit an error`,
553
+ title: `❌ ${workerName} hit an error`,
504
554
  metadata: {
505
- engineer,
555
+ ...baseMetadata,
506
556
  sessionId: event.sessionId,
507
557
  error: event.text.slice(0, 200),
508
558
  },
@@ -511,9 +561,9 @@ function reportClaudeEvent(context, engineer, event) {
511
561
  }
512
562
  if (event.type === 'status') {
513
563
  context.metadata({
514
- title: `ℹ️ ${engineer}: ${event.text}`,
564
+ title: `ℹ️ ${workerName}: ${event.text}`,
515
565
  metadata: {
516
- engineer,
566
+ ...baseMetadata,
517
567
  status: event.text,
518
568
  },
519
569
  });
@@ -521,9 +571,9 @@ function reportClaudeEvent(context, engineer, event) {
521
571
  }
522
572
  if (event.type === 'init') {
523
573
  context.metadata({
524
- title: `⚡ ${engineer} session ready`,
574
+ title: `⚡ ${workerName} session ready`,
525
575
  metadata: {
526
- engineer,
576
+ ...baseMetadata,
527
577
  sessionId: event.sessionId,
528
578
  },
529
579
  });
@@ -557,12 +607,12 @@ function reportClaudeEvent(context, engineer, event) {
557
607
  const toolDescription = formatToolDescription(toolName ?? '', toolArgs);
558
608
  context.metadata({
559
609
  title: toolDescription
560
- ? `⚡ ${engineer} → ${toolDescription}`
610
+ ? `⚡ ${workerName} → ${toolDescription}`
561
611
  : toolName
562
- ? `⚡ ${engineer} → ${toolName}`
563
- : `⚡ ${engineer} is using Claude Code tools`,
612
+ ? `⚡ ${workerName} → ${toolName}`
613
+ : `⚡ ${workerName} is using Claude Code tools`,
564
614
  metadata: {
565
- engineer,
615
+ ...baseMetadata,
566
616
  sessionId: event.sessionId,
567
617
  ...(toolName !== undefined && { toolName }),
568
618
  ...(toolId !== undefined && { toolId }),
@@ -575,9 +625,9 @@ function reportClaudeEvent(context, engineer, event) {
575
625
  const isThinking = event.text.startsWith('<thinking>');
576
626
  const stateLabel = event.type === 'partial' && isThinking ? 'is thinking' : 'is working';
577
627
  context.metadata({
578
- title: `⚡ ${engineer} ${stateLabel}`,
628
+ title: `⚡ ${workerName} ${stateLabel}`,
579
629
  metadata: {
580
- engineer,
630
+ ...baseMetadata,
581
631
  sessionId: event.sessionId,
582
632
  preview: event.text.slice(0, 160),
583
633
  isThinking,
@@ -3,26 +3,27 @@ import { ToolApprovalManager } from '../claude/tool-approval-manager.js';
3
3
  import { PersistentManager } from '../manager/persistent-manager.js';
4
4
  import { TeamOrchestrator } from '../manager/team-orchestrator.js';
5
5
  import { TeamStateStore } from '../state/team-state-store.js';
6
- import type { EngineerName } from '../types/contracts.js';
6
+ import type { EngineerName, WorkerCapabilities } from '../types/contracts.js';
7
7
  interface ClaudeManagerPluginServices {
8
8
  manager: PersistentManager;
9
9
  sessions: ClaudeSessionService;
10
10
  approvalManager: ToolApprovalManager;
11
11
  teamStore: TeamStateStore;
12
12
  orchestrator: TeamOrchestrator;
13
+ workerCapabilities: Partial<Record<EngineerName, WorkerCapabilities>>;
13
14
  }
14
15
  export declare function getOrCreatePluginServices(worktree: string): ClaudeManagerPluginServices;
15
16
  export declare function clearPluginServices(): void;
16
- export declare function setActiveTeamSession(worktree: string, teamId: string): void;
17
- export declare function getActiveTeamSession(worktree: string): string | null;
18
- export declare function getPersistedActiveTeam(worktree: string): Promise<string | null>;
19
- export declare function setPersistedActiveTeam(worktree: string, teamId: string): Promise<void>;
17
+ export declare function registerParentSession(childId: string, parentId: string): void;
18
+ export declare function getParentSessionId(childId: string): string | undefined;
19
+ export declare function registerSessionTeam(sessionId: string, teamId: string): void;
20
+ export declare function getSessionTeam(sessionId: string): string | undefined;
20
21
  export declare function setWrapperSessionMapping(worktree: string, wrapperSessionId: string, mapping: {
21
22
  teamId: string;
22
- engineer: EngineerName;
23
+ workerName: EngineerName;
23
24
  }): void;
24
25
  export declare function getWrapperSessionMapping(worktree: string, wrapperSessionId: string): {
25
26
  teamId: string;
26
- engineer: EngineerName;
27
+ workerName: EngineerName;
27
28
  } | null;
28
29
  export {};
@@ -8,9 +8,13 @@ import { TeamOrchestrator } from '../manager/team-orchestrator.js';
8
8
  import { managerPromptRegistry } from '../prompts/registry.js';
9
9
  import { TeamStateStore } from '../state/team-state-store.js';
10
10
  import { TranscriptStore } from '../state/transcript-store.js';
11
+ import { buildWorkerCapabilities } from './agents/browser-qa.js';
11
12
  const serviceRegistry = new Map();
12
- const activeTeamRegistry = new Map();
13
13
  const wrapperSessionRegistry = new Map();
14
+ /** childSessionId → parentSessionId — populated from session.created events */
15
+ const parentSessionRegistry = new Map();
16
+ /** ctoSessionId → teamId — populated when a CTO chat.message fires */
17
+ const sessionTeamRegistry = new Map();
14
18
  export function getOrCreatePluginServices(worktree) {
15
19
  const existing = serviceRegistry.get(worktree);
16
20
  if (existing) {
@@ -24,41 +28,36 @@ export function getOrCreatePluginServices(worktree) {
24
28
  const teamStore = new TeamStateStore();
25
29
  const transcriptStore = new TranscriptStore();
26
30
  const manager = new PersistentManager(gitOps, transcriptStore);
27
- const orchestrator = new TeamOrchestrator(sessionService, teamStore, transcriptStore, managerPromptRegistry.engineerSessionPrompt, managerPromptRegistry.planSynthesisPrompt);
31
+ const workerCapabilities = buildWorkerCapabilities(managerPromptRegistry);
32
+ const orchestrator = new TeamOrchestrator(sessionService, teamStore, transcriptStore, managerPromptRegistry.engineerSessionPrompt, managerPromptRegistry.planSynthesisPrompt, workerCapabilities);
28
33
  const services = {
29
34
  manager,
30
35
  sessions: sessionService,
31
36
  approvalManager,
32
37
  teamStore,
33
38
  orchestrator,
39
+ workerCapabilities,
34
40
  };
35
41
  serviceRegistry.set(worktree, services);
36
42
  return services;
37
43
  }
38
44
  export function clearPluginServices() {
39
45
  serviceRegistry.clear();
40
- activeTeamRegistry.clear();
41
46
  wrapperSessionRegistry.clear();
47
+ parentSessionRegistry.clear();
48
+ sessionTeamRegistry.clear();
42
49
  }
43
- export function setActiveTeamSession(worktree, teamId) {
44
- activeTeamRegistry.set(worktree, teamId);
50
+ export function registerParentSession(childId, parentId) {
51
+ parentSessionRegistry.set(childId, parentId);
45
52
  }
46
- export function getActiveTeamSession(worktree) {
47
- return activeTeamRegistry.get(worktree) ?? null;
53
+ export function getParentSessionId(childId) {
54
+ return parentSessionRegistry.get(childId);
48
55
  }
49
- export async function getPersistedActiveTeam(worktree) {
50
- const services = serviceRegistry.get(worktree);
51
- if (!services) {
52
- return null;
53
- }
54
- return services.teamStore.getActiveTeam(worktree);
56
+ export function registerSessionTeam(sessionId, teamId) {
57
+ sessionTeamRegistry.set(sessionId, teamId);
55
58
  }
56
- export async function setPersistedActiveTeam(worktree, teamId) {
57
- const services = serviceRegistry.get(worktree);
58
- if (!services) {
59
- return;
60
- }
61
- await services.teamStore.setActiveTeam(worktree, teamId);
59
+ export function getSessionTeam(sessionId) {
60
+ return sessionTeamRegistry.get(sessionId);
62
61
  }
63
62
  export function setWrapperSessionMapping(worktree, wrapperSessionId, mapping) {
64
63
  wrapperSessionRegistry.set(`${worktree}:${wrapperSessionId}`, mapping);
@@ -30,9 +30,10 @@ export const managerPromptRegistry = {
30
30
  '## Delegate: Send precise assignments',
31
31
  "- For single-engineer work: use `task(subagent_type: 'tom'|'john'|'maya'|'sara'|'alex', ...)` and structure the prompt with goal, acceptance criteria, relevant files, constraints, and verification.",
32
32
  "- For dual-engineer planning: use `task(subagent_type: 'team-planner', ...)` which will lead + challenger synthesis.",
33
- '- Each assignment includes: goal, acceptance criteria, relevant files/areas, constraints, and verification method.',
33
+ "- For browser/UI verification: use `task(subagent_type: 'browser-qa', ...)` with a clear verification goal. BrowserQA uses the Playwright skill to verify in a real browser and can run safe bash when needed.",
34
+ '- Each assignment includes: goal, acceptance criteria, relevant context, constraints, and verification method.',
34
35
  '- Reuse the same engineer when follow-up work builds on their prior context.',
35
- '- Only one implementing engineer modifies the worktree at a time. Parallelize exploration and research freely.',
36
+ '- Only one implementing engineer modifies the worktree at a time. Parallelize exploration, research, and browser verification freely.',
36
37
  '',
37
38
  '## Review: Inspect diffs for production safety',
38
39
  '- After an engineer reports implementation done, review the diff with `git_diff` before declaring it complete.',
@@ -149,6 +150,40 @@ export const managerPromptRegistry = {
149
150
  '- If either name is missing, `plan_with_team` will auto-select two non-overlapping engineers based on availability and context.',
150
151
  'Do not attempt any planning or analysis yourself. Delegate entirely to `plan_with_team`.',
151
152
  ].join('\n'),
153
+ browserQaAgentPrompt: [
154
+ "You are the browser QA specialist on the CTO's team.",
155
+ 'Your job is to run browser verification tasks through the `claude` tool.',
156
+ 'The CTO will send tasks requesting you to test a website or web feature using the Playwright skill/command.',
157
+ '',
158
+ 'How to handle verification tasks:',
159
+ '- Extract the verification goal and relevant context from the prompt.',
160
+ '- Use the `claude` tool with mode: "verify" and request Claude Code to use the Playwright skill/command.',
161
+ '- Instruct Claude Code: "Use the Playwright skill/command for real browser testing. If unavailable, report PLAYWRIGHT_UNAVAILABLE: <reason> and stop."',
162
+ '- Return the tool result directly—do not add commentary unless something unexpected occurred.',
163
+ '',
164
+ 'Important:',
165
+ '- Never simulate or fabricate test results.',
166
+ '- If the Playwright tool is not available, the result will start with PLAYWRIGHT_UNAVAILABLE:.',
167
+ '- Your persistent Claude Code session remembers prior verification runs.',
168
+ ].join('\n'),
169
+ browserQaSessionPrompt: [
170
+ 'You are a browser QA specialist. Your job is to verify web features and user flows using the Playwright skill/command.',
171
+ '',
172
+ 'For each verification task:',
173
+ '1. Use the Playwright skill/command to control a real browser.',
174
+ '2. Navigate to the specified URL, interact with the UI, and verify the expected behavior.',
175
+ '3. Take screenshots and collect specific error messages if verification fails.',
176
+ '4. Report results concisely: what was tested, pass/fail status, any errors or unexpected behavior.',
177
+ '',
178
+ 'CRITICAL: If the Playwright skill or command is unavailable (not installed, command not found, skill not loaded):',
179
+ '- Output EXACTLY as the first line of your response:',
180
+ ' PLAYWRIGHT_UNAVAILABLE: <specific reason>',
181
+ '- Do not attempt to verify by other means.',
182
+ '- Do not simulate or fabricate test results.',
183
+ '- Stop after reporting unavailability.',
184
+ '',
185
+ 'Allowed tools: Playwright skill/command, safe bash, read-only tools (Read, Grep, Glob). No file editing or code modifications.',
186
+ ].join('\n'),
152
187
  contextWarnings: {
153
188
  moderate: 'Engineer context is getting full ({percent}% estimated). Reuse is still fine, but keep the next prompt focused.',
154
189
  high: 'Engineer context is heavy ({percent}% estimated, {turns} turns, ${cost}). Prefer a narrowly scoped follow-up or internal compaction.',
@@ -7,10 +7,7 @@ export declare class TeamStateStore {
7
7
  getTeam(cwd: string, teamId: string): Promise<TeamRecord | null>;
8
8
  listTeams(cwd: string): Promise<TeamRecord[]>;
9
9
  updateTeam(cwd: string, teamId: string, update: (team: TeamRecord) => TeamRecord): Promise<TeamRecord>;
10
- getActiveTeam(cwd: string): Promise<string | null>;
11
- setActiveTeam(cwd: string, teamId: string): Promise<void>;
12
10
  private getTeamKey;
13
- private getActiveTeamPath;
14
11
  private getTeamsDirectory;
15
12
  private getTeamPath;
16
13
  private enqueueWrite;
@@ -62,31 +62,9 @@ export class TeamStateStore {
62
62
  return updated;
63
63
  });
64
64
  }
65
- async getActiveTeam(cwd) {
66
- const filePath = this.getActiveTeamPath(cwd);
67
- try {
68
- const content = await fs.readFile(filePath, 'utf8');
69
- const parsed = JSON.parse(content);
70
- return parsed.teamId ?? null;
71
- }
72
- catch (error) {
73
- if (isFileNotFoundError(error)) {
74
- return null;
75
- }
76
- throw error;
77
- }
78
- }
79
- async setActiveTeam(cwd, teamId) {
80
- const filePath = this.getActiveTeamPath(cwd);
81
- await fs.mkdir(path.dirname(filePath), { recursive: true });
82
- await writeJsonAtomically(filePath, { teamId });
83
- }
84
65
  getTeamKey(cwd, teamId) {
85
66
  return `${cwd}:${teamId}`;
86
67
  }
87
- getActiveTeamPath(cwd) {
88
- return path.join(cwd, this.baseDirectoryName, 'active-team.json');
89
- }
90
68
  getTeamsDirectory(cwd) {
91
69
  return path.join(cwd, this.baseDirectoryName, 'teams');
92
70
  }
@@ -1,5 +1,6 @@
1
- import { type EngineerName, type TeamEngineerRecord, type TeamRecord } from '../types/contracts.js';
2
- export declare const TEAM_ENGINEERS: readonly ["Tom", "John", "Maya", "Sara", "Alex"];
1
+ import { PLANNER_ELIGIBLE_ENGINEERS, type EngineerName, type TeamEngineerRecord, type TeamRecord } from '../types/contracts.js';
2
+ export declare const TEAM_ENGINEERS: readonly ["Tom", "John", "Maya", "Sara", "Alex", "BrowserQA"];
3
+ export { PLANNER_ELIGIBLE_ENGINEERS };
3
4
  export declare function isEngineerName(value: string): value is EngineerName;
4
5
  export declare function createEmptyTeamRecord(teamId: string, cwd: string): TeamRecord;
5
6
  export declare function createEmptyEngineerRecord(name: EngineerName): TeamEngineerRecord;
@@ -1,5 +1,6 @@
1
- import { DEFAULT_ENGINEER_NAMES, } from '../types/contracts.js';
1
+ import { DEFAULT_ENGINEER_NAMES, PLANNER_ELIGIBLE_ENGINEERS, } from '../types/contracts.js';
2
2
  export const TEAM_ENGINEERS = DEFAULT_ENGINEER_NAMES;
3
+ export { PLANNER_ELIGIBLE_ENGINEERS };
3
4
  export function isEngineerName(value) {
4
5
  return TEAM_ENGINEERS.includes(value);
5
6
  }
@@ -6,6 +6,10 @@ export interface ManagerPromptRegistry {
6
6
  planSynthesisPrompt: string;
7
7
  /** Visible subagent prompt for teamPlanner — thin bridge that calls plan_with_team. */
8
8
  teamPlannerPrompt: string;
9
+ /** Visible subagent prompt for browserQa — thin bridge that calls claude tool for browser verification. */
10
+ browserQaAgentPrompt: string;
11
+ /** Prompt prepended to browser verification task prompts in Claude Code sessions. */
12
+ browserQaSessionPrompt: string;
9
13
  contextWarnings: {
10
14
  moderate: string;
11
15
  high: string;
@@ -13,7 +17,8 @@ export interface ManagerPromptRegistry {
13
17
  };
14
18
  }
15
19
  export type SessionMode = 'plan' | 'free';
16
- export declare const DEFAULT_ENGINEER_NAMES: readonly ["Tom", "John", "Maya", "Sara", "Alex"];
20
+ export declare const DEFAULT_ENGINEER_NAMES: readonly ["Tom", "John", "Maya", "Sara", "Alex", "BrowserQA"];
21
+ export declare const PLANNER_ELIGIBLE_ENGINEERS: readonly ["Tom", "John", "Maya", "Sara", "Alex"];
17
22
  export type EngineerName = (typeof DEFAULT_ENGINEER_NAMES)[number];
18
23
  export type EngineerWorkMode = 'explore' | 'implement' | 'verify';
19
24
  export interface WrapperHistoryEntry {
@@ -164,6 +169,25 @@ export interface SynthesizedPlanResult {
164
169
  recommendedQuestion: string | null;
165
170
  recommendedAnswer: string | null;
166
171
  }
172
+ /**
173
+ * Per-worker capability config for behavior that differs from the standard engineer path.
174
+ * Keyed by EngineerName in a Partial<Record<EngineerName, WorkerCapabilities>> map;
175
+ * absent entries use default behavior.
176
+ */
177
+ export interface WorkerCapabilities {
178
+ /** Override the session system prompt for this worker. Absent = use standard engineer prompt. */
179
+ sessionPrompt?: string;
180
+ /** Always restrict write tools regardless of mode. Default: false. */
181
+ restrictWriteTools?: boolean;
182
+ /** Skip mode instructions in the task prompt. Default: false. */
183
+ skipModeInstructions?: boolean;
184
+ /** Allow this worker in plan_with_team. Absent or true = eligible. False = excluded. */
185
+ plannerEligible?: boolean;
186
+ /** Returns true if the final output indicates the required runtime is unavailable. */
187
+ isRuntimeUnavailableResponse?: (finalText: string) => boolean;
188
+ /** Metadata title for the runtime-unavailable event. */
189
+ runtimeUnavailableTitle?: string;
190
+ }
167
191
  export interface GitDiffResult {
168
192
  hasDiff: boolean;
169
193
  diffText: string;
@@ -1 +1,2 @@
1
- export const DEFAULT_ENGINEER_NAMES = ['Tom', 'John', 'Maya', 'Sara', 'Alex'];
1
+ export const DEFAULT_ENGINEER_NAMES = ['Tom', 'John', 'Maya', 'Sara', 'Alex', 'BrowserQA'];
2
+ export const PLANNER_ELIGIBLE_ENGINEERS = ['Tom', 'John', 'Maya', 'Sara', 'Alex'];
@@ -7,10 +7,7 @@ export declare class TeamStateStore {
7
7
  getTeam(cwd: string, teamId: string): Promise<TeamRecord | null>;
8
8
  listTeams(cwd: string): Promise<TeamRecord[]>;
9
9
  updateTeam(cwd: string, teamId: string, update: (team: TeamRecord) => TeamRecord): Promise<TeamRecord>;
10
- getActiveTeam(cwd: string): Promise<string | null>;
11
- setActiveTeam(cwd: string, teamId: string): Promise<void>;
12
10
  private getTeamKey;
13
- private getActiveTeamPath;
14
11
  private getTeamsDirectory;
15
12
  private getTeamPath;
16
13
  private enqueueWrite;
@@ -62,31 +62,9 @@ export class TeamStateStore {
62
62
  return updated;
63
63
  });
64
64
  }
65
- async getActiveTeam(cwd) {
66
- const filePath = this.getActiveTeamPath(cwd);
67
- try {
68
- const content = await fs.readFile(filePath, 'utf8');
69
- const parsed = JSON.parse(content);
70
- return parsed.teamId ?? null;
71
- }
72
- catch (error) {
73
- if (isFileNotFoundError(error)) {
74
- return null;
75
- }
76
- throw error;
77
- }
78
- }
79
- async setActiveTeam(cwd, teamId) {
80
- const filePath = this.getActiveTeamPath(cwd);
81
- await fs.mkdir(path.dirname(filePath), { recursive: true });
82
- await writeJsonAtomically(filePath, { teamId });
83
- }
84
65
  getTeamKey(cwd, teamId) {
85
66
  return `${cwd}:${teamId}`;
86
67
  }
87
- getActiveTeamPath(cwd) {
88
- return path.join(cwd, this.baseDirectoryName, 'active-team.json');
89
- }
90
68
  getTeamsDirectory(cwd) {
91
69
  return path.join(cwd, this.baseDirectoryName, 'teams');
92
70
  }
@@ -1,5 +1,6 @@
1
- import { type EngineerName, type TeamEngineerRecord, type TeamRecord } from '../types/contracts.js';
2
- export declare const TEAM_ENGINEERS: readonly ["Tom", "John", "Maya", "Sara", "Alex"];
1
+ import { PLANNER_ELIGIBLE_ENGINEERS, type EngineerName, type TeamEngineerRecord, type TeamRecord } from '../types/contracts.js';
2
+ export declare const TEAM_ENGINEERS: readonly ["Tom", "John", "Maya", "Sara", "Alex", "BrowserQA"];
3
+ export { PLANNER_ELIGIBLE_ENGINEERS };
3
4
  export declare function isEngineerName(value: string): value is EngineerName;
4
5
  export declare function createEmptyTeamRecord(teamId: string, cwd: string): TeamRecord;
5
6
  export declare function createEmptyEngineerRecord(name: EngineerName): TeamEngineerRecord;
@@ -1,5 +1,6 @@
1
- import { DEFAULT_ENGINEER_NAMES, } from '../types/contracts.js';
1
+ import { DEFAULT_ENGINEER_NAMES, PLANNER_ELIGIBLE_ENGINEERS, } from '../types/contracts.js';
2
2
  export const TEAM_ENGINEERS = DEFAULT_ENGINEER_NAMES;
3
+ export { PLANNER_ELIGIBLE_ENGINEERS };
3
4
  export function isEngineerName(value) {
4
5
  return TEAM_ENGINEERS.includes(value);
5
6
  }