@doingdev/opencode-claude-manager-plugin 0.1.51 → 0.1.53

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) {
@@ -289,7 +289,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
289
289
  },
290
290
  }),
291
291
  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.',
292
+ 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
293
  args: {
294
294
  action: tool.schema.enum([
295
295
  'addRule',
@@ -336,6 +336,11 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
336
336
  if (!args.defaultAction) {
337
337
  return JSON.stringify({ error: 'setDefault requires defaultAction' });
338
338
  }
339
+ if (args.defaultAction === 'deny') {
340
+ return JSON.stringify({
341
+ error: 'defaultAction cannot be deny; unmatched tools are always allowed. Add explicit deny rules instead.',
342
+ });
343
+ }
339
344
  await services.approvalManager.setDefaultAction(args.defaultAction);
340
345
  }
341
346
  else if (args.action === 'setEnabled') {
@@ -5,13 +5,22 @@ export const managerPromptRegistry = {
5
5
  'Every prompt you send to an engineer costs time and tokens. Make each one count.',
6
6
  '',
7
7
  'Understand first:',
8
- '- Ask questions. If the request is ambiguous, underspecified, or has multiple valid interpretations, ask before building. Any question whose answer would change what you build or how you build it is worth asking.',
9
- '- Use the `question` tool to surface decisions with a concrete recommendation. Prefer one precise question over many vague ones.',
8
+ '- Before asking the user anything, extract what you can from the user message, codebase (read/grep/glob/codesearch), prior engineer results, and `websearch`/`webfetch` when relevant.',
9
+ '- Ask the user only when the answer would materially change scope, architecture, risk, or how you verify the outcome—and you cannot resolve it from those sources.',
10
+ '- Do not ask for facts you can discover yourself: file paths, current behavior, architecture, or framework conventions.',
11
+ '- Before using `question`, silently check: is it in the user message? answerable from code or transcripts? from web? If still blocked, is this a real decision or only uncertainty tolerance?',
10
12
  '- Identify what already exists in the codebase before creating anything new.',
11
13
  '- Think about what could go wrong and address it upfront.',
12
14
  '- When a bug is reported, always explore the root cause before implementing a fix. No fix without investigation. If three fix attempts fail, question the architecture, not the hypothesis.',
13
15
  '',
16
+ 'Questions (high bar):',
17
+ '- Good questions resolve irreversible choices, product tradeoffs, or ambiguous success criteria that the codebase cannot answer.',
18
+ '- Bad questions ask for information already in context, or vague prompts like "what exactly do you want?" when you can give a concrete recommendation and what would change your mind.',
19
+ '- Each `question` should name the blocked decision, offer 2–3 concrete options, state your recommendation, and what breaks if the user picks differently.',
20
+ '- Use the `question` tool only when you cannot proceed safely from available evidence. One high-leverage question at a time, with a sensible fallback if the user defers.',
21
+ '',
14
22
  'Challenge the framing:',
23
+ '- Not a mandatory opener: if the request is concrete, derive context first; reframe only when it would change what you build.',
15
24
  '- Before planning, ask what the user is actually trying to achieve, not just what they asked for.',
16
25
  '- If the request sounds like a feature ("add photo upload"), ask what job-to-be-done it serves. The real feature might be larger or different.',
17
26
  '- One good reframe question saves more time than ten implementation questions.',
@@ -44,6 +53,7 @@ export const managerPromptRegistry = {
44
53
  '- Do not edit files or run bash directly. Engineers do the hands-on work.',
45
54
  '- Do not read files or grep when an engineer can answer the question faster.',
46
55
  '- Communicate proactively. If the plan changes or you discover something unexpected, tell the user.',
56
+ '- Ask follow-up questions when exploration, engineer results, or diffs expose a product or architecture tradeoff you could not have known at the start. Prefer that timing over opening with speculative clarifiers.',
47
57
  ].join('\n'),
48
58
  engineerAgentPrompt: [
49
59
  "You are a named engineer on the CTO's team.",
@@ -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) {
@@ -289,7 +289,7 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
289
289
  },
290
290
  }),
291
291
  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.',
292
+ 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
293
  args: {
294
294
  action: tool.schema.enum([
295
295
  'addRule',
@@ -336,6 +336,11 @@ export const ClaudeManagerPlugin = async ({ worktree }) => {
336
336
  if (!args.defaultAction) {
337
337
  return JSON.stringify({ error: 'setDefault requires defaultAction' });
338
338
  }
339
+ if (args.defaultAction === 'deny') {
340
+ return JSON.stringify({
341
+ error: 'defaultAction cannot be deny; unmatched tools are always allowed. Add explicit deny rules instead.',
342
+ });
343
+ }
339
344
  await services.approvalManager.setDefaultAction(args.defaultAction);
340
345
  }
341
346
  else if (args.action === 'setEnabled') {
@@ -5,13 +5,22 @@ export const managerPromptRegistry = {
5
5
  'Every prompt you send to an engineer costs time and tokens. Make each one count.',
6
6
  '',
7
7
  'Understand first:',
8
- '- Ask questions. If the request is ambiguous, underspecified, or has multiple valid interpretations, ask before building. Any question whose answer would change what you build or how you build it is worth asking.',
9
- '- Use the `question` tool to surface decisions with a concrete recommendation. Prefer one precise question over many vague ones.',
8
+ '- Before asking the user anything, extract what you can from the user message, codebase (read/grep/glob/codesearch), prior engineer results, and `websearch`/`webfetch` when relevant.',
9
+ '- Ask the user only when the answer would materially change scope, architecture, risk, or how you verify the outcome—and you cannot resolve it from those sources.',
10
+ '- Do not ask for facts you can discover yourself: file paths, current behavior, architecture, or framework conventions.',
11
+ '- Before using `question`, silently check: is it in the user message? answerable from code or transcripts? from web? If still blocked, is this a real decision or only uncertainty tolerance?',
10
12
  '- Identify what already exists in the codebase before creating anything new.',
11
13
  '- Think about what could go wrong and address it upfront.',
12
14
  '- When a bug is reported, always explore the root cause before implementing a fix. No fix without investigation. If three fix attempts fail, question the architecture, not the hypothesis.',
13
15
  '',
16
+ 'Questions (high bar):',
17
+ '- Good questions resolve irreversible choices, product tradeoffs, or ambiguous success criteria that the codebase cannot answer.',
18
+ '- Bad questions ask for information already in context, or vague prompts like "what exactly do you want?" when you can give a concrete recommendation and what would change your mind.',
19
+ '- Each `question` should name the blocked decision, offer 2–3 concrete options, state your recommendation, and what breaks if the user picks differently.',
20
+ '- Use the `question` tool only when you cannot proceed safely from available evidence. One high-leverage question at a time, with a sensible fallback if the user defers.',
21
+ '',
14
22
  'Challenge the framing:',
23
+ '- Not a mandatory opener: if the request is concrete, derive context first; reframe only when it would change what you build.',
15
24
  '- Before planning, ask what the user is actually trying to achieve, not just what they asked for.',
16
25
  '- If the request sounds like a feature ("add photo upload"), ask what job-to-be-done it serves. The real feature might be larger or different.',
17
26
  '- One good reframe question saves more time than ten implementation questions.',
@@ -44,6 +53,7 @@ export const managerPromptRegistry = {
44
53
  '- Do not edit files or run bash directly. Engineers do the hands-on work.',
45
54
  '- Do not read files or grep when an engineer can answer the question faster.',
46
55
  '- Communicate proactively. If the plan changes or you discover something unexpected, tell the user.',
56
+ '- Ask follow-up questions when exploration, engineer results, or diffs expose a product or architecture tradeoff you could not have known at the start. Prefer that timing over opening with speculative clarifiers.',
47
57
  ].join('\n'),
48
58
  engineerAgentPrompt: [
49
59
  "You are a named engineer on the CTO's team.",
@@ -186,7 +186,11 @@ export interface ToolApprovalRule {
186
186
  }
187
187
  export interface ToolApprovalPolicy {
188
188
  rules: ToolApprovalRule[];
189
- defaultAction: 'allow' | 'deny';
189
+ /**
190
+ * Always `allow`: tools that do not match any rule are allowed.
191
+ * Blocking is done only with explicit `deny` rules (deny-list contract).
192
+ */
193
+ defaultAction: 'allow';
190
194
  defaultDenyMessage?: string;
191
195
  enabled: boolean;
192
196
  }
@@ -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);
@@ -186,7 +186,11 @@ export interface ToolApprovalRule {
186
186
  }
187
187
  export interface ToolApprovalPolicy {
188
188
  rules: ToolApprovalRule[];
189
- defaultAction: 'allow' | 'deny';
189
+ /**
190
+ * Always `allow`: tools that do not match any rule are allowed.
191
+ * Blocking is done only with explicit `deny` rules (deny-list contract).
192
+ */
193
+ defaultAction: 'allow';
190
194
  defaultDenyMessage?: string;
191
195
  enabled: boolean;
192
196
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doingdev/opencode-claude-manager-plugin",
3
- "version": "0.1.51",
3
+ "version": "0.1.53",
4
4
  "description": "OpenCode plugin that orchestrates Claude Code sessions.",
5
5
  "keywords": [
6
6
  "opencode",