@doingdev/opencode-claude-manager-plugin 0.1.52 → 0.1.54

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/README.md CHANGED
@@ -81,7 +81,7 @@ If you are testing locally, point OpenCode at the local package or plugin file u
81
81
 
82
82
  - `approval_policy` — view the current tool approval policy.
83
83
  - `approval_decisions` — view recent tool approval decisions.
84
- - `approval_update` — add/remove rules, change default action, or enable/disable approval.
84
+ - `approval_update` — add/remove rules, enable/disable approval, or clear decision history. Policy uses a **deny-list**: tools not matching any rule are **allowed**; use explicit **deny** rules to block. `defaultAction` is always `allow` (cannot be set to deny).
85
85
 
86
86
  ## Agent hierarchy
87
87
 
@@ -3,6 +3,13 @@ import path from 'node:path';
3
3
  import { isFileNotFoundError, writeJsonAtomically } from '../util/fs-helpers.js';
4
4
  const DEFAULT_MAX_DECISIONS = 500;
5
5
  const INPUT_PREVIEW_MAX = 300;
6
+ function normalizePolicy(policy) {
7
+ return {
8
+ ...policy,
9
+ rules: [...policy.rules],
10
+ defaultAction: 'allow',
11
+ };
12
+ }
6
13
  function getDefaultRules() {
7
14
  return [
8
15
  // Safe read-only tools
@@ -125,12 +132,12 @@ export class ToolApprovalManager {
125
132
  maxDecisions;
126
133
  persistPath;
127
134
  constructor(policy, maxDecisions, persistPath) {
128
- this.policy = {
135
+ this.policy = normalizePolicy({
129
136
  rules: policy?.rules ?? getDefaultRules(),
130
- defaultAction: policy?.defaultAction ?? 'allow',
137
+ defaultAction: 'allow',
131
138
  defaultDenyMessage: policy?.defaultDenyMessage ?? 'Tool call denied by approval policy.',
132
139
  enabled: policy?.enabled ?? true,
133
- };
140
+ });
134
141
  this.maxDecisions = maxDecisions ?? DEFAULT_MAX_DECISIONS;
135
142
  this.persistPath = persistPath ?? null;
136
143
  }
@@ -141,12 +148,12 @@ export class ToolApprovalManager {
141
148
  try {
142
149
  const content = await fs.readFile(this.persistPath, 'utf8');
143
150
  const loaded = JSON.parse(content);
144
- this.policy = {
151
+ this.policy = normalizePolicy({
145
152
  rules: loaded.rules ?? getDefaultRules(),
146
- defaultAction: loaded.defaultAction ?? 'allow',
153
+ defaultAction: 'allow',
147
154
  defaultDenyMessage: loaded.defaultDenyMessage ?? 'Tool call denied by approval policy.',
148
155
  enabled: loaded.enabled ?? true,
149
- };
156
+ });
150
157
  }
151
158
  catch (error) {
152
159
  if (!isFileNotFoundError(error)) {
@@ -167,7 +174,7 @@ export class ToolApprovalManager {
167
174
  }
168
175
  const inputJson = safeJsonStringify(input);
169
176
  const matchedRule = this.findMatchingRule(toolName, inputJson);
170
- const action = matchedRule?.action ?? this.policy.defaultAction;
177
+ const action = matchedRule?.action ?? 'allow';
171
178
  const denyMessage = action === 'deny'
172
179
  ? (matchedRule?.denyMessage ?? this.policy.defaultDenyMessage ?? 'Denied by policy.')
173
180
  : undefined;
@@ -201,7 +208,7 @@ export class ToolApprovalManager {
201
208
  return { ...this.policy, rules: [...this.policy.rules] };
202
209
  }
203
210
  async setPolicy(policy) {
204
- this.policy = { ...policy, rules: [...policy.rules] };
211
+ this.policy = normalizePolicy(policy);
205
212
  await this.persistPolicy();
206
213
  }
207
214
  async addRule(rule, position) {
@@ -223,7 +230,10 @@ export class ToolApprovalManager {
223
230
  return true;
224
231
  }
225
232
  async setDefaultAction(action) {
226
- this.policy.defaultAction = action;
233
+ if (action === 'deny') {
234
+ throw new Error('defaultAction cannot be deny; unmatched tools are always allowed. Add explicit deny rules instead.');
235
+ }
236
+ this.policy = normalizePolicy(this.policy);
227
237
  await this.persistPolicy();
228
238
  }
229
239
  async setEnabled(enabled) {
@@ -18,7 +18,8 @@ export declare class TeamOrchestrator {
18
18
  private readonly teamStore;
19
19
  private readonly transcriptStore;
20
20
  private readonly engineerSessionPrompt;
21
- constructor(sessions: ClaudeSessionService, teamStore: TeamStateStore, transcriptStore: TranscriptStore, engineerSessionPrompt: string);
21
+ private readonly architectSystemPrompt;
22
+ constructor(sessions: ClaudeSessionService, teamStore: TeamStateStore, transcriptStore: TranscriptStore, engineerSessionPrompt: string, architectSystemPrompt: string);
22
23
  getOrCreateTeam(cwd: string, teamId: string): Promise<TeamRecord>;
23
24
  listTeams(cwd: string): Promise<TeamRecord[]>;
24
25
  recordWrapperSession(cwd: string, teamId: string, engineer: EngineerName, wrapperSessionId: string): Promise<void>;
@@ -44,6 +45,9 @@ export declare class TeamOrchestrator {
44
45
  challengerEngineer: EngineerName;
45
46
  model?: string;
46
47
  abortSignal?: AbortSignal;
48
+ onLeadEvent?: ClaudeSessionEventHandler;
49
+ onChallengerEvent?: ClaudeSessionEventHandler;
50
+ onSynthesisEvent?: ClaudeSessionEventHandler;
47
51
  }): Promise<SynthesizedPlanResult>;
48
52
  private updateEngineer;
49
53
  private reserveEngineer;
@@ -6,11 +6,13 @@ export class TeamOrchestrator {
6
6
  teamStore;
7
7
  transcriptStore;
8
8
  engineerSessionPrompt;
9
- constructor(sessions, teamStore, transcriptStore, engineerSessionPrompt) {
9
+ architectSystemPrompt;
10
+ constructor(sessions, teamStore, transcriptStore, engineerSessionPrompt, architectSystemPrompt) {
10
11
  this.sessions = sessions;
11
12
  this.teamStore = teamStore;
12
13
  this.transcriptStore = transcriptStore;
13
14
  this.engineerSessionPrompt = engineerSessionPrompt;
15
+ this.architectSystemPrompt = architectSystemPrompt;
14
16
  }
15
17
  async getOrCreateTeam(cwd, teamId) {
16
18
  const existing = await this.teamStore.getTeam(cwd, teamId);
@@ -203,6 +205,7 @@ export class TeamOrchestrator {
203
205
  message: buildPlanDraftRequest('lead', input.request),
204
206
  model: input.model,
205
207
  abortSignal: input.abortSignal,
208
+ onEvent: input.onLeadEvent,
206
209
  }),
207
210
  this.dispatchEngineer({
208
211
  teamId: input.teamId,
@@ -212,6 +215,7 @@ export class TeamOrchestrator {
212
215
  message: buildPlanDraftRequest('challenger', input.request),
213
216
  model: input.model,
214
217
  abortSignal: input.abortSignal,
218
+ onEvent: input.onChallengerEvent,
215
219
  }),
216
220
  ]);
217
221
  const drafts = [
@@ -221,7 +225,7 @@ export class TeamOrchestrator {
221
225
  const synthesisResult = await this.sessions.runTask({
222
226
  cwd: input.cwd,
223
227
  prompt: buildSynthesisPrompt(input.request, drafts),
224
- systemPrompt: buildSynthesisSystemPrompt(),
228
+ systemPrompt: buildSynthesisSystemPrompt(this.architectSystemPrompt),
225
229
  persistSession: false,
226
230
  includePartialMessages: false,
227
231
  permissionMode: 'acceptEdits',
@@ -230,7 +234,7 @@ export class TeamOrchestrator {
230
234
  effort: 'high',
231
235
  settingSources: ['user', 'project', 'local'],
232
236
  abortSignal: input.abortSignal,
233
- });
237
+ }, input.onSynthesisEvent);
234
238
  const parsedSynthesis = parseSynthesisResult(synthesisResult.finalText);
235
239
  return {
236
240
  teamId: input.teamId,
@@ -361,20 +365,8 @@ function buildPlanDraftRequest(perspective, request) {
361
365
  `User request: ${request}`,
362
366
  ].join('\n');
363
367
  }
364
- function buildSynthesisSystemPrompt() {
365
- return [
366
- 'You are synthesizing two independent engineering plans into one stronger plan.',
367
- 'Compare them on clarity, feasibility, risk, and fit to the user request.',
368
- 'Prefer the simplest path that fully addresses the goal.',
369
- 'If the plans disagree on something only the user can decide, surface exactly one recommended question and one recommended answer.',
370
- 'Use this output format exactly:',
371
- '## Synthesis',
372
- '<combined plan>',
373
- '## Recommended Question',
374
- '<question or NONE>',
375
- '## Recommended Answer',
376
- '<answer or NONE>',
377
- ].join('\n');
368
+ function buildSynthesisSystemPrompt(architectSystemPrompt) {
369
+ return architectSystemPrompt;
378
370
  }
379
371
  function buildSynthesisPrompt(request, drafts) {
380
372
  return [
@@ -1,5 +1,6 @@
1
1
  import type { EngineerName, ManagerPromptRegistry } from '../types/contracts.js';
2
2
  export declare const AGENT_CTO = "cto";
3
+ export declare const AGENT_ARCHITECT = "architect";
3
4
  export declare const ENGINEER_AGENT_IDS: {
4
5
  readonly Tom: "tom";
5
6
  readonly John: "john";
@@ -41,5 +42,13 @@ export declare function buildEngineerAgentConfig(prompts: ManagerPromptRegistry,
41
42
  permission: AgentPermission;
42
43
  prompt: string;
43
44
  };
45
+ export declare function buildArchitectAgentConfig(prompts: ManagerPromptRegistry): {
46
+ description: string;
47
+ mode: "subagent";
48
+ hidden: boolean;
49
+ color: string;
50
+ permission: AgentPermission;
51
+ prompt: string;
52
+ };
44
53
  export declare function denyRestrictedToolsGlobally(permissions: Record<string, ToolPermission>): void;
45
54
  export {};
@@ -1,5 +1,6 @@
1
1
  import { TEAM_ENGINEERS } from '../team/roster.js';
2
2
  export const AGENT_CTO = 'cto';
3
+ export const AGENT_ARCHITECT = 'architect';
3
4
  export const ENGINEER_AGENT_IDS = {
4
5
  Tom: 'tom',
5
6
  John: 'john',
@@ -10,7 +11,6 @@ export const ENGINEER_AGENT_IDS = {
10
11
  export const ENGINEER_AGENT_NAMES = TEAM_ENGINEERS;
11
12
  const CTO_ONLY_TOOL_IDS = [
12
13
  'team_status',
13
- 'plan_with_team',
14
14
  'reset_engineer',
15
15
  'list_transcripts',
16
16
  'list_history',
@@ -38,6 +38,27 @@ const CTO_READONLY_TOOLS = {
38
38
  todoread: 'allow',
39
39
  question: 'allow',
40
40
  };
41
+ function buildArchitectPermissions() {
42
+ const denied = {};
43
+ for (const toolId of ALL_RESTRICTED_TOOL_IDS) {
44
+ denied[toolId] = 'deny';
45
+ }
46
+ return {
47
+ '*': 'deny',
48
+ read: 'allow',
49
+ grep: 'allow',
50
+ glob: 'allow',
51
+ list: 'allow',
52
+ codesearch: 'allow',
53
+ webfetch: 'deny',
54
+ websearch: 'deny',
55
+ lsp: 'deny',
56
+ todowrite: 'deny',
57
+ todoread: 'deny',
58
+ question: 'deny',
59
+ ...denied,
60
+ };
61
+ }
41
62
  function buildCtoPermissions() {
42
63
  const denied = {};
43
64
  for (const toolId of ALL_RESTRICTED_TOOL_IDS) {
@@ -51,6 +72,7 @@ function buildCtoPermissions() {
51
72
  for (const engineer of ENGINEER_AGENT_NAMES) {
52
73
  taskPermissions[ENGINEER_AGENT_IDS[engineer]] = 'allow';
53
74
  }
75
+ taskPermissions[AGENT_ARCHITECT] = 'allow';
54
76
  return {
55
77
  '*': 'deny',
56
78
  ...CTO_READONLY_TOOLS,
@@ -89,6 +111,16 @@ export function buildEngineerAgentConfig(prompts, engineer) {
89
111
  prompt: `You are ${engineer}.\n\n${prompts.engineerAgentPrompt}`,
90
112
  };
91
113
  }
114
+ export function buildArchitectAgentConfig(prompts) {
115
+ return {
116
+ description: 'Synthesizes two engineer plan drafts into one stronger, actionable plan.',
117
+ mode: 'subagent',
118
+ hidden: false,
119
+ color: '#D97757',
120
+ permission: buildArchitectPermissions(),
121
+ prompt: prompts.architectSystemPrompt,
122
+ };
123
+ }
92
124
  export function denyRestrictedToolsGlobally(permissions) {
93
125
  for (const toolId of ALL_RESTRICTED_TOOL_IDS) {
94
126
  permissions[toolId] ??= 'deny';
@@ -2,7 +2,7 @@ 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 } from '../manager/team-orchestrator.js';
5
- import { AGENT_CTO, ENGINEER_AGENT_IDS, ENGINEER_AGENT_NAMES, buildCtoAgentConfig, buildEngineerAgentConfig, denyRestrictedToolsGlobally, } from './agent-hierarchy.js';
5
+ import { AGENT_CTO, AGENT_ARCHITECT, ENGINEER_AGENT_IDS, ENGINEER_AGENT_NAMES, buildCtoAgentConfig, buildEngineerAgentConfig, buildArchitectAgentConfig, denyRestrictedToolsGlobally, } from './agent-hierarchy.js';
6
6
  import { getActiveTeamSession, getOrCreatePluginServices, getPersistedActiveTeam, getWrapperSessionMapping, setActiveTeamSession, setPersistedActiveTeam, 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'];
@@ -15,6 +15,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
15
15
  config.permission ??= {};
16
16
  denyRestrictedToolsGlobally(config.permission);
17
17
  config.agent[AGENT_CTO] ??= buildCtoAgentConfig(managerPromptRegistry);
18
+ config.agent[AGENT_ARCHITECT] ??= buildArchitectAgentConfig(managerPromptRegistry);
18
19
  for (const engineer of ENGINEER_AGENT_NAMES) {
19
20
  config.agent[ENGINEER_AGENT_IDS[engineer]] ??= buildEngineerAgentConfig(managerPromptRegistry, engineer);
20
21
  }
@@ -126,9 +127,12 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
126
127
  challengerEngineer: args.challengerEngineer,
127
128
  model: args.model,
128
129
  abortSignal: context.abort,
130
+ onLeadEvent: (event) => reportClaudeEvent(context, args.leadEngineer, event),
131
+ onChallengerEvent: (event) => reportClaudeEvent(context, args.challengerEngineer, event),
132
+ onSynthesisEvent: (event) => reportArchitectEvent(context, event),
129
133
  });
130
134
  context.metadata({
131
- title: '✅ Plan synthesis complete',
135
+ title: '✅ Architect finished',
132
136
  metadata: {
133
137
  teamId: result.teamId,
134
138
  lead: result.leadEngineer,
@@ -289,7 +293,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
289
293
  },
290
294
  }),
291
295
  approval_update: tool({
292
- description: 'Update the tool approval policy. Add or remove rules, change the default action, enable or disable approvals, or clear decision history.',
296
+ description: 'Update the tool approval policy. Add or remove rules, enable or disable approvals, or clear decision history. Unmatched tools are always allowed; block only with explicit deny rules (defaultAction cannot be set to deny).',
293
297
  args: {
294
298
  action: tool.schema.enum([
295
299
  'addRule',
@@ -336,6 +340,11 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
336
340
  if (!args.defaultAction) {
337
341
  return JSON.stringify({ error: 'setDefault requires defaultAction' });
338
342
  }
343
+ if (args.defaultAction === 'deny') {
344
+ return JSON.stringify({
345
+ error: 'defaultAction cannot be deny; unmatched tools are always allowed. Add explicit deny rules instead.',
346
+ });
347
+ }
339
348
  await services.approvalManager.setDefaultAction(args.defaultAction);
340
349
  }
341
350
  else if (args.action === 'setEnabled') {
@@ -553,6 +562,78 @@ function reportClaudeEvent(context, engineer, event) {
553
562
  });
554
563
  }
555
564
  }
565
+ function reportArchitectEvent(context, event) {
566
+ if (event.type === 'error') {
567
+ context.metadata({
568
+ title: `❌ Architect hit an error`,
569
+ metadata: {
570
+ sessionId: event.sessionId,
571
+ error: event.text.slice(0, 200),
572
+ },
573
+ });
574
+ return;
575
+ }
576
+ if (event.type === 'init') {
577
+ context.metadata({
578
+ title: `⚡ Architect session ready`,
579
+ metadata: {
580
+ sessionId: event.sessionId,
581
+ },
582
+ });
583
+ return;
584
+ }
585
+ if (event.type === 'tool_call') {
586
+ let toolName;
587
+ let toolId;
588
+ let toolArgs;
589
+ try {
590
+ const parsed = JSON.parse(event.text);
591
+ toolName = parsed.name;
592
+ toolId = parsed.id;
593
+ if (typeof parsed.input === 'string') {
594
+ try {
595
+ toolArgs = JSON.parse(parsed.input);
596
+ }
597
+ catch {
598
+ toolArgs = parsed.input;
599
+ }
600
+ }
601
+ else {
602
+ toolArgs = parsed.input;
603
+ }
604
+ }
605
+ catch {
606
+ // event.text is not valid JSON — fall back to generic title
607
+ }
608
+ const toolDescription = formatToolDescription(toolName ?? '', toolArgs);
609
+ context.metadata({
610
+ title: toolDescription
611
+ ? `⚡ Architect → ${toolDescription}`
612
+ : toolName
613
+ ? `⚡ Architect → ${toolName}`
614
+ : `⚡ Architect is using Claude Code tools`,
615
+ metadata: {
616
+ sessionId: event.sessionId,
617
+ ...(toolName !== undefined && { toolName }),
618
+ ...(toolId !== undefined && { toolId }),
619
+ ...(toolArgs !== undefined && { toolArgs }),
620
+ },
621
+ });
622
+ return;
623
+ }
624
+ if (event.type === 'assistant' || event.type === 'partial') {
625
+ const isThinking = event.text.startsWith('<thinking>');
626
+ const stateLabel = event.type === 'partial' && isThinking ? 'is thinking' : 'is working';
627
+ context.metadata({
628
+ title: `⚡ Architect ${stateLabel}`,
629
+ metadata: {
630
+ sessionId: event.sessionId,
631
+ preview: event.text.slice(0, 160),
632
+ isThinking,
633
+ },
634
+ });
635
+ }
636
+ }
556
637
  function annotateToolRun(context, title, metadata) {
557
638
  context.metadata({
558
639
  title,
@@ -24,7 +24,7 @@ export function getOrCreatePluginServices(worktree) {
24
24
  const teamStore = new TeamStateStore();
25
25
  const transcriptStore = new TranscriptStore();
26
26
  const manager = new PersistentManager(gitOps, transcriptStore);
27
- const orchestrator = new TeamOrchestrator(sessionService, teamStore, transcriptStore, managerPromptRegistry.engineerSessionPrompt);
27
+ const orchestrator = new TeamOrchestrator(sessionService, teamStore, transcriptStore, managerPromptRegistry.engineerSessionPrompt, managerPromptRegistry.architectSystemPrompt);
28
28
  const services = {
29
29
  manager,
30
30
  sessions: sessionService,
@@ -27,7 +27,7 @@ export const managerPromptRegistry = {
27
27
  '',
28
28
  'Plan and decompose:',
29
29
  '- Break work into independent pieces that can run in parallel. Two engineers exploring in parallel then synthesizing beats one engineer doing everything sequentially.',
30
- '- For medium or large tasks, dispatch two engineers with complementary perspectives (lead plan + challenger review), then synthesize.',
30
+ '- For medium or large tasks, delegate dual-engineer exploration to two engineers, then task the `architect` subagent to synthesize their independent plans into one stronger plan.',
31
31
  '- Define clear success criteria before delegating. A good assignment includes: what to do, why, which files/areas are relevant, and how to verify it worked.',
32
32
  '',
33
33
  'Delegate through the Task tool:',
@@ -76,6 +76,21 @@ export const managerPromptRegistry = {
76
76
  'Report blockers immediately with exact error output. Do not retry silently more than once.',
77
77
  'Do not run git commit, git push, git reset, git checkout, or git stash.',
78
78
  ].join('\n'),
79
+ architectSystemPrompt: [
80
+ 'You are the Architect. Your role is to synthesize two independent engineering plans into one stronger, unified plan.',
81
+ 'Compare the lead and challenger plans on clarity, feasibility, risk, and fit to the user request.',
82
+ 'Prefer the simplest path that fully addresses the goal. Surface tradeoffs honestly.',
83
+ 'If the plans disagree on something only the user can decide, surface exactly one recommended question and one recommended answer.',
84
+ 'Do not editorialize or over-explain. Be direct and concise.',
85
+ '',
86
+ 'Use this output format exactly:',
87
+ '## Synthesis',
88
+ '<combined plan>',
89
+ '## Recommended Question',
90
+ '<question or NONE>',
91
+ '## Recommended Answer',
92
+ '<answer or NONE>',
93
+ ].join('\n'),
79
94
  contextWarnings: {
80
95
  moderate: 'Engineer context is getting full ({percent}% estimated). Reuse is still fine, but keep the next prompt focused.',
81
96
  high: 'Engineer context is heavy ({percent}% estimated, {turns} turns, ${cost}). Prefer a narrowly scoped follow-up or internal compaction.',
@@ -3,6 +3,13 @@ import path from 'node:path';
3
3
  import { isFileNotFoundError, writeJsonAtomically } from '../util/fs-helpers.js';
4
4
  const DEFAULT_MAX_DECISIONS = 500;
5
5
  const INPUT_PREVIEW_MAX = 300;
6
+ function normalizePolicy(policy) {
7
+ return {
8
+ ...policy,
9
+ rules: [...policy.rules],
10
+ defaultAction: 'allow',
11
+ };
12
+ }
6
13
  function getDefaultRules() {
7
14
  return [
8
15
  // Safe read-only tools
@@ -125,12 +132,12 @@ export class ToolApprovalManager {
125
132
  maxDecisions;
126
133
  persistPath;
127
134
  constructor(policy, maxDecisions, persistPath) {
128
- this.policy = {
135
+ this.policy = normalizePolicy({
129
136
  rules: policy?.rules ?? getDefaultRules(),
130
- defaultAction: policy?.defaultAction ?? 'allow',
137
+ defaultAction: 'allow',
131
138
  defaultDenyMessage: policy?.defaultDenyMessage ?? 'Tool call denied by approval policy.',
132
139
  enabled: policy?.enabled ?? true,
133
- };
140
+ });
134
141
  this.maxDecisions = maxDecisions ?? DEFAULT_MAX_DECISIONS;
135
142
  this.persistPath = persistPath ?? null;
136
143
  }
@@ -141,12 +148,12 @@ export class ToolApprovalManager {
141
148
  try {
142
149
  const content = await fs.readFile(this.persistPath, 'utf8');
143
150
  const loaded = JSON.parse(content);
144
- this.policy = {
151
+ this.policy = normalizePolicy({
145
152
  rules: loaded.rules ?? getDefaultRules(),
146
- defaultAction: loaded.defaultAction ?? 'allow',
153
+ defaultAction: 'allow',
147
154
  defaultDenyMessage: loaded.defaultDenyMessage ?? 'Tool call denied by approval policy.',
148
155
  enabled: loaded.enabled ?? true,
149
- };
156
+ });
150
157
  }
151
158
  catch (error) {
152
159
  if (!isFileNotFoundError(error)) {
@@ -167,7 +174,7 @@ export class ToolApprovalManager {
167
174
  }
168
175
  const inputJson = safeJsonStringify(input);
169
176
  const matchedRule = this.findMatchingRule(toolName, inputJson);
170
- const action = matchedRule?.action ?? this.policy.defaultAction;
177
+ const action = matchedRule?.action ?? 'allow';
171
178
  const denyMessage = action === 'deny'
172
179
  ? (matchedRule?.denyMessage ?? this.policy.defaultDenyMessage ?? 'Denied by policy.')
173
180
  : undefined;
@@ -201,7 +208,7 @@ export class ToolApprovalManager {
201
208
  return { ...this.policy, rules: [...this.policy.rules] };
202
209
  }
203
210
  async setPolicy(policy) {
204
- this.policy = { ...policy, rules: [...policy.rules] };
211
+ this.policy = normalizePolicy(policy);
205
212
  await this.persistPolicy();
206
213
  }
207
214
  async addRule(rule, position) {
@@ -223,7 +230,10 @@ export class ToolApprovalManager {
223
230
  return true;
224
231
  }
225
232
  async setDefaultAction(action) {
226
- this.policy.defaultAction = action;
233
+ if (action === 'deny') {
234
+ throw new Error('defaultAction cannot be deny; unmatched tools are always allowed. Add explicit deny rules instead.');
235
+ }
236
+ this.policy = normalizePolicy(this.policy);
227
237
  await this.persistPolicy();
228
238
  }
229
239
  async setEnabled(enabled) {
@@ -18,7 +18,8 @@ export declare class TeamOrchestrator {
18
18
  private readonly teamStore;
19
19
  private readonly transcriptStore;
20
20
  private readonly engineerSessionPrompt;
21
- constructor(sessions: ClaudeSessionService, teamStore: TeamStateStore, transcriptStore: TranscriptStore, engineerSessionPrompt: string);
21
+ private readonly architectSystemPrompt;
22
+ constructor(sessions: ClaudeSessionService, teamStore: TeamStateStore, transcriptStore: TranscriptStore, engineerSessionPrompt: string, architectSystemPrompt: string);
22
23
  getOrCreateTeam(cwd: string, teamId: string): Promise<TeamRecord>;
23
24
  listTeams(cwd: string): Promise<TeamRecord[]>;
24
25
  recordWrapperSession(cwd: string, teamId: string, engineer: EngineerName, wrapperSessionId: string): Promise<void>;
@@ -44,6 +45,9 @@ export declare class TeamOrchestrator {
44
45
  challengerEngineer: EngineerName;
45
46
  model?: string;
46
47
  abortSignal?: AbortSignal;
48
+ onLeadEvent?: ClaudeSessionEventHandler;
49
+ onChallengerEvent?: ClaudeSessionEventHandler;
50
+ onSynthesisEvent?: ClaudeSessionEventHandler;
47
51
  }): Promise<SynthesizedPlanResult>;
48
52
  private updateEngineer;
49
53
  private reserveEngineer;
@@ -6,11 +6,13 @@ export class TeamOrchestrator {
6
6
  teamStore;
7
7
  transcriptStore;
8
8
  engineerSessionPrompt;
9
- constructor(sessions, teamStore, transcriptStore, engineerSessionPrompt) {
9
+ architectSystemPrompt;
10
+ constructor(sessions, teamStore, transcriptStore, engineerSessionPrompt, architectSystemPrompt) {
10
11
  this.sessions = sessions;
11
12
  this.teamStore = teamStore;
12
13
  this.transcriptStore = transcriptStore;
13
14
  this.engineerSessionPrompt = engineerSessionPrompt;
15
+ this.architectSystemPrompt = architectSystemPrompt;
14
16
  }
15
17
  async getOrCreateTeam(cwd, teamId) {
16
18
  const existing = await this.teamStore.getTeam(cwd, teamId);
@@ -203,6 +205,7 @@ export class TeamOrchestrator {
203
205
  message: buildPlanDraftRequest('lead', input.request),
204
206
  model: input.model,
205
207
  abortSignal: input.abortSignal,
208
+ onEvent: input.onLeadEvent,
206
209
  }),
207
210
  this.dispatchEngineer({
208
211
  teamId: input.teamId,
@@ -212,6 +215,7 @@ export class TeamOrchestrator {
212
215
  message: buildPlanDraftRequest('challenger', input.request),
213
216
  model: input.model,
214
217
  abortSignal: input.abortSignal,
218
+ onEvent: input.onChallengerEvent,
215
219
  }),
216
220
  ]);
217
221
  const drafts = [
@@ -221,7 +225,7 @@ export class TeamOrchestrator {
221
225
  const synthesisResult = await this.sessions.runTask({
222
226
  cwd: input.cwd,
223
227
  prompt: buildSynthesisPrompt(input.request, drafts),
224
- systemPrompt: buildSynthesisSystemPrompt(),
228
+ systemPrompt: buildSynthesisSystemPrompt(this.architectSystemPrompt),
225
229
  persistSession: false,
226
230
  includePartialMessages: false,
227
231
  permissionMode: 'acceptEdits',
@@ -230,7 +234,7 @@ export class TeamOrchestrator {
230
234
  effort: 'high',
231
235
  settingSources: ['user', 'project', 'local'],
232
236
  abortSignal: input.abortSignal,
233
- });
237
+ }, input.onSynthesisEvent);
234
238
  const parsedSynthesis = parseSynthesisResult(synthesisResult.finalText);
235
239
  return {
236
240
  teamId: input.teamId,
@@ -361,20 +365,8 @@ function buildPlanDraftRequest(perspective, request) {
361
365
  `User request: ${request}`,
362
366
  ].join('\n');
363
367
  }
364
- function buildSynthesisSystemPrompt() {
365
- return [
366
- 'You are synthesizing two independent engineering plans into one stronger plan.',
367
- 'Compare them on clarity, feasibility, risk, and fit to the user request.',
368
- 'Prefer the simplest path that fully addresses the goal.',
369
- 'If the plans disagree on something only the user can decide, surface exactly one recommended question and one recommended answer.',
370
- 'Use this output format exactly:',
371
- '## Synthesis',
372
- '<combined plan>',
373
- '## Recommended Question',
374
- '<question or NONE>',
375
- '## Recommended Answer',
376
- '<answer or NONE>',
377
- ].join('\n');
368
+ function buildSynthesisSystemPrompt(architectSystemPrompt) {
369
+ return architectSystemPrompt;
378
370
  }
379
371
  function buildSynthesisPrompt(request, drafts) {
380
372
  return [
@@ -1,5 +1,6 @@
1
1
  import type { EngineerName, ManagerPromptRegistry } from '../types/contracts.js';
2
2
  export declare const AGENT_CTO = "cto";
3
+ export declare const AGENT_ARCHITECT = "architect";
3
4
  export declare const ENGINEER_AGENT_IDS: {
4
5
  readonly Tom: "tom";
5
6
  readonly John: "john";
@@ -41,5 +42,13 @@ export declare function buildEngineerAgentConfig(prompts: ManagerPromptRegistry,
41
42
  permission: AgentPermission;
42
43
  prompt: string;
43
44
  };
45
+ export declare function buildArchitectAgentConfig(prompts: ManagerPromptRegistry): {
46
+ description: string;
47
+ mode: "subagent";
48
+ hidden: boolean;
49
+ color: string;
50
+ permission: AgentPermission;
51
+ prompt: string;
52
+ };
44
53
  export declare function denyRestrictedToolsGlobally(permissions: Record<string, ToolPermission>): void;
45
54
  export {};
@@ -1,5 +1,6 @@
1
1
  import { TEAM_ENGINEERS } from '../team/roster.js';
2
2
  export const AGENT_CTO = 'cto';
3
+ export const AGENT_ARCHITECT = 'architect';
3
4
  export const ENGINEER_AGENT_IDS = {
4
5
  Tom: 'tom',
5
6
  John: 'john',
@@ -10,7 +11,6 @@ export const ENGINEER_AGENT_IDS = {
10
11
  export const ENGINEER_AGENT_NAMES = TEAM_ENGINEERS;
11
12
  const CTO_ONLY_TOOL_IDS = [
12
13
  'team_status',
13
- 'plan_with_team',
14
14
  'reset_engineer',
15
15
  'list_transcripts',
16
16
  'list_history',
@@ -38,6 +38,27 @@ const CTO_READONLY_TOOLS = {
38
38
  todoread: 'allow',
39
39
  question: 'allow',
40
40
  };
41
+ function buildArchitectPermissions() {
42
+ const denied = {};
43
+ for (const toolId of ALL_RESTRICTED_TOOL_IDS) {
44
+ denied[toolId] = 'deny';
45
+ }
46
+ return {
47
+ '*': 'deny',
48
+ read: 'allow',
49
+ grep: 'allow',
50
+ glob: 'allow',
51
+ list: 'allow',
52
+ codesearch: 'allow',
53
+ webfetch: 'deny',
54
+ websearch: 'deny',
55
+ lsp: 'deny',
56
+ todowrite: 'deny',
57
+ todoread: 'deny',
58
+ question: 'deny',
59
+ ...denied,
60
+ };
61
+ }
41
62
  function buildCtoPermissions() {
42
63
  const denied = {};
43
64
  for (const toolId of ALL_RESTRICTED_TOOL_IDS) {
@@ -51,6 +72,7 @@ function buildCtoPermissions() {
51
72
  for (const engineer of ENGINEER_AGENT_NAMES) {
52
73
  taskPermissions[ENGINEER_AGENT_IDS[engineer]] = 'allow';
53
74
  }
75
+ taskPermissions[AGENT_ARCHITECT] = 'allow';
54
76
  return {
55
77
  '*': 'deny',
56
78
  ...CTO_READONLY_TOOLS,
@@ -89,6 +111,16 @@ export function buildEngineerAgentConfig(prompts, engineer) {
89
111
  prompt: `You are ${engineer}.\n\n${prompts.engineerAgentPrompt}`,
90
112
  };
91
113
  }
114
+ export function buildArchitectAgentConfig(prompts) {
115
+ return {
116
+ description: 'Synthesizes two engineer plan drafts into one stronger, actionable plan.',
117
+ mode: 'subagent',
118
+ hidden: false,
119
+ color: '#D97757',
120
+ permission: buildArchitectPermissions(),
121
+ prompt: prompts.architectSystemPrompt,
122
+ };
123
+ }
92
124
  export function denyRestrictedToolsGlobally(permissions) {
93
125
  for (const toolId of ALL_RESTRICTED_TOOL_IDS) {
94
126
  permissions[toolId] ??= 'deny';
@@ -2,7 +2,7 @@ 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 } from '../manager/team-orchestrator.js';
5
- import { AGENT_CTO, ENGINEER_AGENT_IDS, ENGINEER_AGENT_NAMES, buildCtoAgentConfig, buildEngineerAgentConfig, denyRestrictedToolsGlobally, } from './agent-hierarchy.js';
5
+ import { AGENT_CTO, AGENT_ARCHITECT, ENGINEER_AGENT_IDS, ENGINEER_AGENT_NAMES, buildCtoAgentConfig, buildEngineerAgentConfig, buildArchitectAgentConfig, denyRestrictedToolsGlobally, } from './agent-hierarchy.js';
6
6
  import { getActiveTeamSession, getOrCreatePluginServices, getPersistedActiveTeam, getWrapperSessionMapping, setActiveTeamSession, setPersistedActiveTeam, 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'];
@@ -15,6 +15,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
15
15
  config.permission ??= {};
16
16
  denyRestrictedToolsGlobally(config.permission);
17
17
  config.agent[AGENT_CTO] ??= buildCtoAgentConfig(managerPromptRegistry);
18
+ config.agent[AGENT_ARCHITECT] ??= buildArchitectAgentConfig(managerPromptRegistry);
18
19
  for (const engineer of ENGINEER_AGENT_NAMES) {
19
20
  config.agent[ENGINEER_AGENT_IDS[engineer]] ??= buildEngineerAgentConfig(managerPromptRegistry, engineer);
20
21
  }
@@ -126,9 +127,12 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
126
127
  challengerEngineer: args.challengerEngineer,
127
128
  model: args.model,
128
129
  abortSignal: context.abort,
130
+ onLeadEvent: (event) => reportClaudeEvent(context, args.leadEngineer, event),
131
+ onChallengerEvent: (event) => reportClaudeEvent(context, args.challengerEngineer, event),
132
+ onSynthesisEvent: (event) => reportArchitectEvent(context, event),
129
133
  });
130
134
  context.metadata({
131
- title: '✅ Plan synthesis complete',
135
+ title: '✅ Architect finished',
132
136
  metadata: {
133
137
  teamId: result.teamId,
134
138
  lead: result.leadEngineer,
@@ -289,7 +293,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
289
293
  },
290
294
  }),
291
295
  approval_update: tool({
292
- description: 'Update the tool approval policy. Add or remove rules, change the default action, enable or disable approvals, or clear decision history.',
296
+ description: 'Update the tool approval policy. Add or remove rules, enable or disable approvals, or clear decision history. Unmatched tools are always allowed; block only with explicit deny rules (defaultAction cannot be set to deny).',
293
297
  args: {
294
298
  action: tool.schema.enum([
295
299
  'addRule',
@@ -336,6 +340,11 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
336
340
  if (!args.defaultAction) {
337
341
  return JSON.stringify({ error: 'setDefault requires defaultAction' });
338
342
  }
343
+ if (args.defaultAction === 'deny') {
344
+ return JSON.stringify({
345
+ error: 'defaultAction cannot be deny; unmatched tools are always allowed. Add explicit deny rules instead.',
346
+ });
347
+ }
339
348
  await services.approvalManager.setDefaultAction(args.defaultAction);
340
349
  }
341
350
  else if (args.action === 'setEnabled') {
@@ -553,6 +562,78 @@ function reportClaudeEvent(context, engineer, event) {
553
562
  });
554
563
  }
555
564
  }
565
+ function reportArchitectEvent(context, event) {
566
+ if (event.type === 'error') {
567
+ context.metadata({
568
+ title: `❌ Architect hit an error`,
569
+ metadata: {
570
+ sessionId: event.sessionId,
571
+ error: event.text.slice(0, 200),
572
+ },
573
+ });
574
+ return;
575
+ }
576
+ if (event.type === 'init') {
577
+ context.metadata({
578
+ title: `⚡ Architect session ready`,
579
+ metadata: {
580
+ sessionId: event.sessionId,
581
+ },
582
+ });
583
+ return;
584
+ }
585
+ if (event.type === 'tool_call') {
586
+ let toolName;
587
+ let toolId;
588
+ let toolArgs;
589
+ try {
590
+ const parsed = JSON.parse(event.text);
591
+ toolName = parsed.name;
592
+ toolId = parsed.id;
593
+ if (typeof parsed.input === 'string') {
594
+ try {
595
+ toolArgs = JSON.parse(parsed.input);
596
+ }
597
+ catch {
598
+ toolArgs = parsed.input;
599
+ }
600
+ }
601
+ else {
602
+ toolArgs = parsed.input;
603
+ }
604
+ }
605
+ catch {
606
+ // event.text is not valid JSON — fall back to generic title
607
+ }
608
+ const toolDescription = formatToolDescription(toolName ?? '', toolArgs);
609
+ context.metadata({
610
+ title: toolDescription
611
+ ? `⚡ Architect → ${toolDescription}`
612
+ : toolName
613
+ ? `⚡ Architect → ${toolName}`
614
+ : `⚡ Architect is using Claude Code tools`,
615
+ metadata: {
616
+ sessionId: event.sessionId,
617
+ ...(toolName !== undefined && { toolName }),
618
+ ...(toolId !== undefined && { toolId }),
619
+ ...(toolArgs !== undefined && { toolArgs }),
620
+ },
621
+ });
622
+ return;
623
+ }
624
+ if (event.type === 'assistant' || event.type === 'partial') {
625
+ const isThinking = event.text.startsWith('<thinking>');
626
+ const stateLabel = event.type === 'partial' && isThinking ? 'is thinking' : 'is working';
627
+ context.metadata({
628
+ title: `⚡ Architect ${stateLabel}`,
629
+ metadata: {
630
+ sessionId: event.sessionId,
631
+ preview: event.text.slice(0, 160),
632
+ isThinking,
633
+ },
634
+ });
635
+ }
636
+ }
556
637
  function annotateToolRun(context, title, metadata) {
557
638
  context.metadata({
558
639
  title,
@@ -24,7 +24,7 @@ export function getOrCreatePluginServices(worktree) {
24
24
  const teamStore = new TeamStateStore();
25
25
  const transcriptStore = new TranscriptStore();
26
26
  const manager = new PersistentManager(gitOps, transcriptStore);
27
- const orchestrator = new TeamOrchestrator(sessionService, teamStore, transcriptStore, managerPromptRegistry.engineerSessionPrompt);
27
+ const orchestrator = new TeamOrchestrator(sessionService, teamStore, transcriptStore, managerPromptRegistry.engineerSessionPrompt, managerPromptRegistry.architectSystemPrompt);
28
28
  const services = {
29
29
  manager,
30
30
  sessions: sessionService,
@@ -27,7 +27,7 @@ export const managerPromptRegistry = {
27
27
  '',
28
28
  'Plan and decompose:',
29
29
  '- Break work into independent pieces that can run in parallel. Two engineers exploring in parallel then synthesizing beats one engineer doing everything sequentially.',
30
- '- For medium or large tasks, dispatch two engineers with complementary perspectives (lead plan + challenger review), then synthesize.',
30
+ '- For medium or large tasks, delegate dual-engineer exploration to two engineers, then task the `architect` subagent to synthesize their independent plans into one stronger plan.',
31
31
  '- Define clear success criteria before delegating. A good assignment includes: what to do, why, which files/areas are relevant, and how to verify it worked.',
32
32
  '',
33
33
  'Delegate through the Task tool:',
@@ -76,6 +76,21 @@ export const managerPromptRegistry = {
76
76
  'Report blockers immediately with exact error output. Do not retry silently more than once.',
77
77
  'Do not run git commit, git push, git reset, git checkout, or git stash.',
78
78
  ].join('\n'),
79
+ architectSystemPrompt: [
80
+ 'You are the Architect. Your role is to synthesize two independent engineering plans into one stronger, unified plan.',
81
+ 'Compare the lead and challenger plans on clarity, feasibility, risk, and fit to the user request.',
82
+ 'Prefer the simplest path that fully addresses the goal. Surface tradeoffs honestly.',
83
+ 'If the plans disagree on something only the user can decide, surface exactly one recommended question and one recommended answer.',
84
+ 'Do not editorialize or over-explain. Be direct and concise.',
85
+ '',
86
+ 'Use this output format exactly:',
87
+ '## Synthesis',
88
+ '<combined plan>',
89
+ '## Recommended Question',
90
+ '<question or NONE>',
91
+ '## Recommended Answer',
92
+ '<answer or NONE>',
93
+ ].join('\n'),
79
94
  contextWarnings: {
80
95
  moderate: 'Engineer context is getting full ({percent}% estimated). Reuse is still fine, but keep the next prompt focused.',
81
96
  high: 'Engineer context is heavy ({percent}% estimated, {turns} turns, ${cost}). Prefer a narrowly scoped follow-up or internal compaction.',
@@ -2,6 +2,7 @@ export interface ManagerPromptRegistry {
2
2
  ctoSystemPrompt: string;
3
3
  engineerAgentPrompt: string;
4
4
  engineerSessionPrompt: string;
5
+ architectSystemPrompt: string;
5
6
  contextWarnings: {
6
7
  moderate: string;
7
8
  high: string;
@@ -186,7 +187,11 @@ export interface ToolApprovalRule {
186
187
  }
187
188
  export interface ToolApprovalPolicy {
188
189
  rules: ToolApprovalRule[];
189
- defaultAction: 'allow' | 'deny';
190
+ /**
191
+ * Always `allow`: tools that do not match any rule are allowed.
192
+ * Blocking is done only with explicit `deny` rules (deny-list contract).
193
+ */
194
+ defaultAction: 'allow';
190
195
  defaultDenyMessage?: string;
191
196
  enabled: boolean;
192
197
  }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { ClaudeManagerPlugin } from '../src/plugin/claude-manager.plugin.js';
3
- import { AGENT_CTO, ENGINEER_AGENT_IDS, ENGINEER_AGENT_NAMES, } from '../src/plugin/agent-hierarchy.js';
3
+ import { AGENT_CTO, AGENT_ARCHITECT, ENGINEER_AGENT_IDS, ENGINEER_AGENT_NAMES, } from '../src/plugin/agent-hierarchy.js';
4
4
  describe('ClaudeManagerPlugin', () => {
5
5
  it('configures CTO with orchestration tools and question access', async () => {
6
6
  const plugin = await ClaudeManagerPlugin({
@@ -26,7 +26,6 @@ describe('ClaudeManagerPlugin', () => {
26
26
  todoread: 'allow',
27
27
  question: 'allow',
28
28
  team_status: 'allow',
29
- plan_with_team: 'allow',
30
29
  reset_engineer: 'allow',
31
30
  git_diff: 'allow',
32
31
  git_commit: 'allow',
@@ -42,6 +41,7 @@ describe('ClaudeManagerPlugin', () => {
42
41
  maya: 'allow',
43
42
  sara: 'allow',
44
43
  alex: 'allow',
44
+ architect: 'allow',
45
45
  });
46
46
  });
47
47
  it('configures every named engineer with only the claude bridge tool', async () => {
@@ -63,13 +63,33 @@ describe('ClaudeManagerPlugin', () => {
63
63
  claude: 'allow',
64
64
  git_diff: 'deny',
65
65
  git_commit: 'deny',
66
- plan_with_team: 'deny',
67
66
  reset_engineer: 'deny',
68
67
  });
69
68
  expect(agent.permission).not.toHaveProperty('read');
70
69
  expect(agent.permission).not.toHaveProperty('grep');
71
70
  }
72
71
  });
72
+ it('configures architect as a read-only subagent for plan synthesis', async () => {
73
+ const plugin = await ClaudeManagerPlugin({
74
+ worktree: '/tmp/project',
75
+ });
76
+ const config = {};
77
+ await plugin.config?.(config);
78
+ const agents = (config.agent ?? {});
79
+ const architect = agents[AGENT_ARCHITECT];
80
+ expect(architect).toBeDefined();
81
+ expect(architect.mode).toBe('subagent');
82
+ expect(architect.description.toLowerCase()).toContain('synthesiz');
83
+ expect(architect.permission).toMatchObject({
84
+ '*': 'deny',
85
+ read: 'allow',
86
+ grep: 'allow',
87
+ glob: 'allow',
88
+ list: 'allow',
89
+ codesearch: 'allow',
90
+ claude: 'deny',
91
+ });
92
+ });
73
93
  it('registers the named engineer bridge and team status tools', async () => {
74
94
  const plugin = await ClaudeManagerPlugin({
75
95
  worktree: '/tmp/project',
@@ -4,7 +4,8 @@ describe('managerPromptRegistry', () => {
4
4
  it('gives the CTO explicit orchestration guidance', () => {
5
5
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('You are a principal engineer orchestrating a team of AI-powered engineers');
6
6
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('Task tool');
7
- expect(managerPromptRegistry.ctoSystemPrompt).toContain('dispatch two engineers with complementary perspectives');
7
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('dual-engineer');
8
+ expect(managerPromptRegistry.ctoSystemPrompt).toContain('architect');
8
9
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('question');
9
10
  expect(managerPromptRegistry.ctoSystemPrompt).toContain('Tom, John, Maya, Sara, and Alex');
10
11
  expect(managerPromptRegistry.ctoSystemPrompt).not.toContain('clear_session');
@@ -28,4 +29,12 @@ describe('managerPromptRegistry', () => {
28
29
  expect(managerPromptRegistry.contextWarnings.high).toContain('{turns}');
29
30
  expect(managerPromptRegistry.contextWarnings.critical).toContain('near capacity');
30
31
  });
32
+ it('gives the architect synthesis guidance with complete output format', () => {
33
+ expect(managerPromptRegistry.architectSystemPrompt).toContain('Architect');
34
+ expect(managerPromptRegistry.architectSystemPrompt).toContain('synthesiz');
35
+ expect(managerPromptRegistry.architectSystemPrompt).toContain('two independent');
36
+ expect(managerPromptRegistry.architectSystemPrompt).toContain('## Synthesis');
37
+ expect(managerPromptRegistry.architectSystemPrompt).toContain('## Recommended Question');
38
+ expect(managerPromptRegistry.architectSystemPrompt).toContain('## Recommended Answer');
39
+ });
31
40
  });
@@ -180,7 +180,7 @@ describe('second invocation continuity', () => {
180
180
  // ── Phase 1: first task via orchestrator (no real SDK needed) ──────────
181
181
  const store = new TeamStateStore();
182
182
  await store.setActiveTeam(tempRoot, 'cto-1');
183
- const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => undefined) }, 'Base prompt');
183
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => undefined) }, 'Base prompt', 'Architect prompt');
184
184
  await orchestrator.recordWrapperSession(tempRoot, 'cto-1', 'Tom', 'wrapper-tom-1');
185
185
  await orchestrator.recordWrapperExchange(tempRoot, 'cto-1', 'Tom', 'wrapper-tom-1', 'explore', 'Investigate the auth flow', 'Found two race conditions in the token refresh path.');
186
186
  // ── Phase 2: process restart ───────────────────────────────────────────
@@ -206,7 +206,7 @@ describe('second invocation continuity', () => {
206
206
  // ── Phase 1: pre-seed Tom with a claudeSessionId ───────────────────────
207
207
  const store = new TeamStateStore();
208
208
  await store.setActiveTeam(tempRoot, 'cto-1');
209
- const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => undefined) }, 'Base prompt');
209
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => undefined) }, 'Base prompt', 'Architect prompt');
210
210
  await orchestrator.getOrCreateTeam(tempRoot, 'cto-1');
211
211
  await store.updateTeam(tempRoot, 'cto-1', (team) => ({
212
212
  ...team,
@@ -35,7 +35,7 @@ describe('TeamOrchestrator', () => {
35
35
  outputTokens: 300,
36
36
  contextWindowSize: 200_000,
37
37
  });
38
- const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt');
38
+ const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Architect prompt');
39
39
  const first = await orchestrator.dispatchEngineer({
40
40
  teamId: 'team-1',
41
41
  cwd: tempRoot,
@@ -74,7 +74,7 @@ describe('TeamOrchestrator', () => {
74
74
  it('rejects work when the same engineer is already busy', async () => {
75
75
  tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
76
76
  const store = new TeamStateStore('.state');
77
- const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt');
77
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, store, { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Architect prompt');
78
78
  const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
79
79
  await store.saveTeam({
80
80
  ...team,
@@ -112,7 +112,7 @@ describe('TeamOrchestrator', () => {
112
112
  events: [],
113
113
  finalText: '## Synthesis\nCombined plan\n## Recommended Question\nShould we migrate now?\n## Recommended Answer\nNo, defer it.',
114
114
  });
115
- const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt');
115
+ const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Architect prompt');
116
116
  const result = await orchestrator.planWithTeam({
117
117
  teamId: 'team-1',
118
118
  cwd: tempRoot,
@@ -126,9 +126,58 @@ describe('TeamOrchestrator', () => {
126
126
  expect(result.recommendedAnswer).toBe('No, defer it.');
127
127
  expect(runTask).toHaveBeenCalledTimes(3);
128
128
  });
129
+ it('invokes lead, challenger, and synthesis event callbacks', async () => {
130
+ tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
131
+ const runTask = vi.fn(async (input, onEvent) => {
132
+ // Simulate event callbacks being invoked by the session
133
+ if (onEvent) {
134
+ await Promise.resolve(onEvent({ type: 'init', text: 'initialized' }));
135
+ }
136
+ // Return appropriate result based on call count
137
+ const calls = runTask.mock.calls.length;
138
+ if (calls === 1) {
139
+ return {
140
+ sessionId: 'ses_tom',
141
+ events: [{ type: 'init', text: 'initialized', sessionId: 'ses_tom' }],
142
+ finalText: 'Lead plan',
143
+ };
144
+ }
145
+ else if (calls === 2) {
146
+ return {
147
+ sessionId: 'ses_maya',
148
+ events: [{ type: 'init', text: 'initialized', sessionId: 'ses_maya' }],
149
+ finalText: 'Challenger plan',
150
+ };
151
+ }
152
+ else {
153
+ return {
154
+ sessionId: undefined,
155
+ events: [{ type: 'init', text: 'initialized', sessionId: undefined }],
156
+ finalText: '## Synthesis\nSynthesis\n## Recommended Question\nNONE\n## Recommended Answer\nNONE',
157
+ };
158
+ }
159
+ });
160
+ const orchestrator = new TeamOrchestrator({ runTask }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Architect prompt');
161
+ const onLeadEvent = vi.fn();
162
+ const onChallengerEvent = vi.fn();
163
+ const onSynthesisEvent = vi.fn();
164
+ await orchestrator.planWithTeam({
165
+ teamId: 'team-1',
166
+ cwd: tempRoot,
167
+ request: 'Plan the refactor',
168
+ leadEngineer: 'Tom',
169
+ challengerEngineer: 'Maya',
170
+ onLeadEvent,
171
+ onChallengerEvent,
172
+ onSynthesisEvent,
173
+ });
174
+ expect(onLeadEvent).toHaveBeenCalled();
175
+ expect(onChallengerEvent).toHaveBeenCalled();
176
+ expect(onSynthesisEvent).toHaveBeenCalled();
177
+ });
129
178
  it('persists wrapper session memory for an engineer', async () => {
130
179
  tempRoot = await mkdtemp(join(tmpdir(), 'team-orchestrator-'));
131
- const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt');
180
+ const orchestrator = new TeamOrchestrator({ runTask: vi.fn() }, new TeamStateStore('.state'), { appendEvents: vi.fn(async () => { }) }, 'Base engineer prompt', 'Architect prompt');
132
181
  await orchestrator.recordWrapperSession(tempRoot, 'team-1', 'Tom', 'wrapper-tom');
133
182
  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.');
134
183
  const team = await orchestrator.getOrCreateTeam(tempRoot, 'team-1');
@@ -73,13 +73,12 @@ describe('ToolApprovalManager', () => {
73
73
  const result = manager.evaluate('Bash', { command: 'rm -rf /' });
74
74
  expect(result).toEqual({ behavior: 'allow' });
75
75
  });
76
- it('uses default action when no rule matches', () => {
76
+ it('allows tools when no rule matches (deny-list contract)', () => {
77
77
  const manager = new ToolApprovalManager({
78
78
  rules: [],
79
- defaultAction: 'deny',
80
79
  });
81
80
  const result = manager.evaluate('SomeUnknownTool', {});
82
- expect(result.behavior).toBe('deny');
81
+ expect(result.behavior).toBe('allow');
83
82
  });
84
83
  it('first matching rule wins', () => {
85
84
  const manager = new ToolApprovalManager({
@@ -175,24 +174,22 @@ describe('ToolApprovalManager', () => {
175
174
  const manager = new ToolApprovalManager();
176
175
  expect(await manager.removeRule('nonexistent')).toBe(false);
177
176
  });
178
- it('setPolicy replaces entire policy', async () => {
177
+ it('setPolicy replaces entire policy and normalizes defaultAction to allow', async () => {
179
178
  const manager = new ToolApprovalManager();
180
179
  await manager.setPolicy({
181
180
  rules: [{ id: 'only', toolPattern: '*', action: 'deny' }],
182
- defaultAction: 'deny',
181
+ defaultAction: 'allow',
183
182
  enabled: true,
184
183
  });
185
184
  expect(manager.getPolicy().rules).toHaveLength(1);
186
- expect(manager.getPolicy().defaultAction).toBe('deny');
185
+ expect(manager.getPolicy().defaultAction).toBe('allow');
187
186
  });
188
- it('setDefaultAction changes the fallback', async () => {
187
+ it('setDefaultAction rejects deny', async () => {
189
188
  const manager = new ToolApprovalManager({
190
189
  rules: [],
191
190
  enabled: true,
192
- defaultAction: 'allow',
193
191
  });
194
- await manager.setDefaultAction('deny');
195
- expect(manager.evaluate('Unknown', {}).behavior).toBe('deny');
192
+ await expect(manager.setDefaultAction('deny')).rejects.toThrow(/defaultAction cannot be deny/);
196
193
  });
197
194
  it('setEnabled toggles the manager', async () => {
198
195
  const manager = new ToolApprovalManager({
@@ -205,7 +202,6 @@ describe('ToolApprovalManager', () => {
205
202
  },
206
203
  ],
207
204
  enabled: true,
208
- defaultAction: 'deny',
209
205
  });
210
206
  expect(manager.evaluate('Read', {}).behavior).toBe('deny');
211
207
  await manager.setEnabled(false);
@@ -2,6 +2,7 @@ export interface ManagerPromptRegistry {
2
2
  ctoSystemPrompt: string;
3
3
  engineerAgentPrompt: string;
4
4
  engineerSessionPrompt: string;
5
+ architectSystemPrompt: string;
5
6
  contextWarnings: {
6
7
  moderate: string;
7
8
  high: string;
@@ -186,7 +187,11 @@ export interface ToolApprovalRule {
186
187
  }
187
188
  export interface ToolApprovalPolicy {
188
189
  rules: ToolApprovalRule[];
189
- defaultAction: 'allow' | 'deny';
190
+ /**
191
+ * Always `allow`: tools that do not match any rule are allowed.
192
+ * Blocking is done only with explicit `deny` rules (deny-list contract).
193
+ */
194
+ defaultAction: 'allow';
190
195
  defaultDenyMessage?: string;
191
196
  enabled: boolean;
192
197
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doingdev/opencode-claude-manager-plugin",
3
- "version": "0.1.52",
3
+ "version": "0.1.54",
4
4
  "description": "OpenCode plugin that orchestrates Claude Code sessions.",
5
5
  "keywords": [
6
6
  "opencode",