@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 +1 -1
- package/dist/claude/tool-approval-manager.js +19 -9
- package/dist/manager/team-orchestrator.d.ts +5 -1
- package/dist/manager/team-orchestrator.js +9 -17
- package/dist/plugin/agent-hierarchy.d.ts +9 -0
- package/dist/plugin/agent-hierarchy.js +33 -1
- package/dist/plugin/claude-manager.plugin.js +84 -3
- package/dist/plugin/service-factory.js +1 -1
- package/dist/prompts/registry.js +16 -1
- package/dist/src/claude/tool-approval-manager.js +19 -9
- package/dist/src/manager/team-orchestrator.d.ts +5 -1
- package/dist/src/manager/team-orchestrator.js +9 -17
- package/dist/src/plugin/agent-hierarchy.d.ts +9 -0
- package/dist/src/plugin/agent-hierarchy.js +33 -1
- package/dist/src/plugin/claude-manager.plugin.js +84 -3
- package/dist/src/plugin/service-factory.js +1 -1
- package/dist/src/prompts/registry.js +16 -1
- package/dist/src/types/contracts.d.ts +6 -1
- package/dist/test/claude-manager.plugin.test.js +23 -3
- package/dist/test/prompt-registry.test.js +10 -1
- package/dist/test/report-claude-event.test.js +2 -2
- package/dist/test/team-orchestrator.test.js +53 -4
- package/dist/test/tool-approval-manager.test.js +7 -11
- package/dist/types/contracts.d.ts +6 -1
- package/package.json +1 -1
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,
|
|
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:
|
|
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:
|
|
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 ??
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '✅
|
|
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,
|
|
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,
|
package/dist/prompts/registry.js
CHANGED
|
@@ -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,
|
|
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:
|
|
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:
|
|
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 ??
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '✅
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
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: '
|
|
181
|
+
defaultAction: 'allow',
|
|
183
182
|
enabled: true,
|
|
184
183
|
});
|
|
185
184
|
expect(manager.getPolicy().rules).toHaveLength(1);
|
|
186
|
-
expect(manager.getPolicy().defaultAction).toBe('
|
|
185
|
+
expect(manager.getPolicy().defaultAction).toBe('allow');
|
|
187
186
|
});
|
|
188
|
-
it('setDefaultAction
|
|
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
|
-
|
|
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
|
}
|