@hobin/developer 0.1.4 → 0.1.6

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.
@@ -1,5 +1,16 @@
1
- export const PROTOCOL = "developer/v3" as const;
2
- export const PREVIOUS_PROTOCOL = "developer/v2" as const;
1
+ import { applyDeveloperEvent, developerSnapshot, initialState } from "./machine.ts";
2
+
3
+ export {
4
+ applyDeveloperEvent,
5
+ canApplyDeveloperEvent,
6
+ developerMachine,
7
+ developerSnapshot,
8
+ initialState,
9
+ } from "./machine.ts";
10
+
11
+ export const PROTOCOL = "developer/v4" as const;
12
+ export const PREVIOUS_PROTOCOL = "developer/v3" as const;
13
+ export const OLDER_PROTOCOL = "developer/v2" as const;
3
14
  export const LEGACY_PROTOCOL = "developer/v1" as const;
4
15
  export const MODE_ENTRY = "developer.mode" as const;
5
16
  export const FOCUS_ENTRY = "developer.question-focus" as const;
@@ -7,10 +18,17 @@ export const ROUTE_TOOL = "developer_route_question" as const;
7
18
  export const JUDGMENT_TOOL = "developer_record_judgment" as const;
8
19
  export const LEGACY_ROUTE_TOOL = "route_question" as const;
9
20
  export const LEGACY_JUDGMENT_TOOL = "record_judgment" as const;
21
+ export const MAX_RESPONSE_FIELDS = 20;
22
+ export const MAX_RESPONSE_OPTIONS = 20;
23
+ export const MAX_RESPONSE_IDENTIFIER_CHARS = 64;
24
+ export const MAX_RESPONSE_TEXT_CHARS = 2_000;
10
25
 
11
26
  export type DeveloperMode = "off" | "on" | "strict";
12
27
  export type JudgmentStatus = "resolved" | "needs-evidence" | "not-applicable" | "blocked";
13
- export type PendingQuestionStatus = "needs-evidence" | "blocked";
28
+ export type PendingQuestionStatus = "open" | "blocked";
29
+ export type QuestionResolutionOwner = "agent" | "user" | "environment" | "unknown";
30
+ export type QuestionGate = "none" | "before-direct" | "before-completion";
31
+ export type QuestionUpdateStatus = "resolved" | "not-applicable" | "open" | "blocked";
14
32
  export type DirectExecutionProfile = "ordinary" | "behavior-preserving-structure";
15
33
 
16
34
  export interface ModeEvent {
@@ -31,6 +49,11 @@ export interface DirectStepContract {
31
49
  verification: string;
32
50
  }
33
51
 
52
+ export interface RouteAlternative {
53
+ owner: string;
54
+ reason: string;
55
+ }
56
+
34
57
  export interface RouteEvent {
35
58
  protocol: typeof PROTOCOL;
36
59
  kind: "route";
@@ -39,19 +62,51 @@ export interface RouteEvent {
39
62
  owner: string;
40
63
  reason: string;
41
64
  knownEvidence: string[];
65
+ consideredAlternatives: RouteAlternative[];
42
66
  targetQuestionId?: string;
43
67
  methodLocation?: string;
44
68
  executionProfile?: DirectExecutionProfile;
45
69
  directStep?: DirectStepContract;
46
70
  }
47
71
 
72
+ export interface ChoiceResponseOption {
73
+ value: string;
74
+ label: string;
75
+ description?: string;
76
+ detailPrompt?: string;
77
+ }
78
+
79
+ export interface ChoiceResponseField {
80
+ id: string;
81
+ prompt: string;
82
+ description?: string;
83
+ options: ChoiceResponseOption[];
84
+ }
85
+
86
+ export interface ChoiceResponseSpec {
87
+ kind: "choice-form";
88
+ fields: ChoiceResponseField[];
89
+ }
90
+
48
91
  export interface PendingQuestion {
49
92
  id: string;
50
93
  question: string;
94
+ context?: string;
95
+ responseSpec?: ChoiceResponseSpec;
51
96
  status: PendingQuestionStatus;
97
+ resolutionOwner: QuestionResolutionOwner;
98
+ gate: QuestionGate;
99
+ resolutionCriteria: string;
52
100
  sourceRouteId: string;
53
101
  }
54
102
 
103
+ export interface QuestionUpdate {
104
+ questionId: string;
105
+ status: QuestionUpdateStatus;
106
+ result: string;
107
+ basis: string[];
108
+ }
109
+
55
110
  export interface JudgmentEvent {
56
111
  protocol: typeof PROTOCOL;
57
112
  kind: "judgment";
@@ -62,6 +117,7 @@ export interface JudgmentEvent {
62
117
  result: string;
63
118
  basis: string[];
64
119
  openedQuestions: PendingQuestion[];
120
+ questionUpdates: QuestionUpdate[];
65
121
  artifacts: string[];
66
122
  changedArtifacts: boolean;
67
123
  }
@@ -73,108 +129,40 @@ export interface DeveloperState {
73
129
  activeRoute?: RouteEvent;
74
130
  lastRoute?: RouteEvent;
75
131
  lastJudgment?: JudgmentEvent;
132
+ routeHistory: RouteEvent[];
133
+ judgmentHistory: JudgmentEvent[];
76
134
  pendingQuestions: PendingQuestion[];
77
135
  focusedQuestionId?: string;
136
+ rerouteRequired: boolean;
78
137
  implementationFramingRequired: boolean;
79
138
  verificationRequired: boolean;
80
139
  }
81
140
 
82
- export const initialState = (): DeveloperState => ({
83
- mode: "off",
84
- pendingQuestions: [],
85
- implementationFramingRequired: false,
86
- verificationRequired: false,
87
- });
88
-
89
- export type ProtocolState = "idle" | "needs-judgment" | "needs-evidence" | "needs-verification" | "blocked";
141
+ export type ProtocolState =
142
+ | "idle"
143
+ | "needs-judgment"
144
+ | "needs-evidence"
145
+ | "needs-answer"
146
+ | "needs-routing"
147
+ | "needs-verification"
148
+ | "blocked";
90
149
 
91
150
  export function protocolState(state: DeveloperState): ProtocolState {
92
- if (state.activeRoute) return "needs-judgment";
93
- if (state.pendingQuestions.some((question) => question.status === "blocked")) return "blocked";
94
- if (state.pendingQuestions.length > 0) return "needs-evidence";
95
- if (state.verificationRequired) return "needs-verification";
151
+ const snapshot = developerSnapshot(state);
152
+ if (!snapshot.matches({ route: "idle" })) return "needs-judgment";
153
+ if (
154
+ snapshot.hasTag("blocks-direct") ||
155
+ state.pendingQuestions.some((question) => question.status === "blocked")
156
+ )
157
+ return "blocked";
158
+ if (state.pendingQuestions.some((question) => question.resolutionOwner === "user"))
159
+ return "needs-answer";
160
+ if (snapshot.matches({ questions: "open" })) return "needs-evidence";
161
+ if (snapshot.hasTag("reroute-required")) return "needs-routing";
162
+ if (snapshot.hasTag("verification-required")) return "needs-verification";
96
163
  return "idle";
97
164
  }
98
165
 
99
- function upsertQuestion(questions: PendingQuestion[], next: PendingQuestion): PendingQuestion[] {
100
- return [...questions.filter((question) => question.id !== next.id), next];
101
- }
102
-
103
- export function applyDeveloperEvent(state: DeveloperState, event: DeveloperEvent): DeveloperState {
104
- if (event.kind === "mode") {
105
- if (event.mode === "off") return initialState();
106
- return { ...state, mode: event.mode };
107
- }
108
-
109
- if (event.kind === "focus") {
110
- if (!state.pendingQuestions.some((question) => question.id === event.questionId)) return state;
111
- return { ...state, focusedQuestionId: event.questionId };
112
- }
113
-
114
- if (event.kind === "route") {
115
- if (state.activeRoute) return state;
116
- return {
117
- ...state,
118
- activeRoute: event,
119
- lastRoute: event,
120
- focusedQuestionId:
121
- event.targetQuestionId === state.focusedQuestionId ? undefined : state.focusedQuestionId,
122
- };
123
- }
124
-
125
- if (!state.activeRoute || state.activeRoute.routeId !== event.routeId) return state;
126
-
127
- const route = state.activeRoute;
128
- const judgment = { ...event, question: route.question, owner: route.owner };
129
- let pending = [...state.pendingQuestions];
130
- const remainsOpen = event.status === "needs-evidence" || event.status === "blocked";
131
-
132
- if (route.targetQuestionId) {
133
- pending = pending.filter((question) => question.id !== route.targetQuestionId);
134
- if (remainsOpen) {
135
- pending = upsertQuestion(pending, {
136
- id: route.targetQuestionId,
137
- question: route.question,
138
- status: event.status === "blocked" ? "blocked" : "needs-evidence",
139
- sourceRouteId: route.routeId,
140
- });
141
- }
142
- } else if (remainsOpen) {
143
- pending = upsertQuestion(pending, {
144
- id: `question:${route.routeId}`,
145
- question: route.question,
146
- status: event.status === "blocked" ? "blocked" : "needs-evidence",
147
- sourceRouteId: route.routeId,
148
- });
149
- }
150
-
151
- for (const question of event.openedQuestions) pending = upsertQuestion(pending, question);
152
-
153
- const closesFramingGate =
154
- (route.owner === "sketch" || route.owner === "signal") &&
155
- (event.status === "resolved" || event.status === "not-applicable");
156
- const opensFramingGate = route.owner === "model" && event.status === "resolved";
157
- let implementationFramingRequired = state.implementationFramingRequired;
158
- if (opensFramingGate) implementationFramingRequired = true;
159
- if (closesFramingGate) implementationFramingRequired = false;
160
-
161
- let verificationRequired = state.verificationRequired;
162
- if (route.owner === "direct" && event.changedArtifacts) verificationRequired = true;
163
- if (route.owner === "verify" && event.status === "resolved") verificationRequired = false;
164
-
165
- return {
166
- ...state,
167
- activeRoute: undefined,
168
- lastJudgment: judgment,
169
- pendingQuestions: pending,
170
- focusedQuestionId: pending.some((question) => question.id === state.focusedQuestionId)
171
- ? state.focusedQuestionId
172
- : undefined,
173
- implementationFramingRequired,
174
- verificationRequired,
175
- };
176
- }
177
-
178
166
  function isObject(value: unknown): value is Record<string, unknown> {
179
167
  return Boolean(value) && typeof value === "object";
180
168
  }
@@ -188,7 +176,12 @@ function isMode(value: unknown): value is DeveloperMode {
188
176
  }
189
177
 
190
178
  function isJudgmentStatus(value: unknown): value is JudgmentStatus {
191
- return value === "resolved" || value === "needs-evidence" || value === "not-applicable" || value === "blocked";
179
+ return (
180
+ value === "resolved" ||
181
+ value === "needs-evidence" ||
182
+ value === "not-applicable" ||
183
+ value === "blocked"
184
+ );
192
185
  }
193
186
 
194
187
  function isDirectExecutionProfile(value: unknown): value is DirectExecutionProfile {
@@ -211,31 +204,158 @@ function parseDirectStep(value: unknown): DirectStepContract | undefined {
211
204
  };
212
205
  }
213
206
 
207
+ function parseRouteAlternative(value: unknown): RouteAlternative | undefined {
208
+ if (!isObject(value) || typeof value.owner !== "string" || typeof value.reason !== "string") {
209
+ return undefined;
210
+ }
211
+ return { owner: value.owner, reason: value.reason };
212
+ }
213
+
214
+ function isQuestionResolutionOwner(value: unknown): value is QuestionResolutionOwner {
215
+ return value === "agent" || value === "user" || value === "environment" || value === "unknown";
216
+ }
217
+
218
+ function isQuestionGate(value: unknown): value is QuestionGate {
219
+ return value === "none" || value === "before-direct" || value === "before-completion";
220
+ }
221
+
222
+ function isQuestionUpdateStatus(value: unknown): value is QuestionUpdateStatus {
223
+ return (
224
+ value === "resolved" || value === "not-applicable" || value === "open" || value === "blocked"
225
+ );
226
+ }
227
+
228
+ const RESPONSE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
229
+
230
+ function requiredResponseText(value: unknown, maxChars: number): string | undefined {
231
+ if (typeof value !== "string") return undefined;
232
+ const text = value.trim();
233
+ return text && text.length <= maxChars ? text : undefined;
234
+ }
235
+
236
+ function optionalResponseText(value: unknown): string | undefined | null {
237
+ if (value === undefined) return undefined;
238
+ return requiredResponseText(value, MAX_RESPONSE_TEXT_CHARS) ?? null;
239
+ }
240
+
241
+ function parseChoiceResponseOption(
242
+ value: unknown,
243
+ seenValues: Set<string>,
244
+ ): ChoiceResponseOption | undefined {
245
+ if (!isObject(value)) return undefined;
246
+ const optionValue = requiredResponseText(value.value, MAX_RESPONSE_IDENTIFIER_CHARS);
247
+ const label = requiredResponseText(value.label, MAX_RESPONSE_TEXT_CHARS);
248
+ const description = optionalResponseText(value.description);
249
+ const detailPrompt = optionalResponseText(value.detailPrompt);
250
+ if (!optionValue || !RESPONSE_IDENTIFIER.test(optionValue)) return undefined;
251
+ if (seenValues.has(optionValue) || !label) return undefined;
252
+ if (description === null || detailPrompt === null) return undefined;
253
+ seenValues.add(optionValue);
254
+ return { value: optionValue, label, description, detailPrompt };
255
+ }
256
+
257
+ function parseChoiceResponseField(
258
+ value: unknown,
259
+ seenIds: Set<string>,
260
+ ): ChoiceResponseField | undefined {
261
+ if (!isObject(value) || !Array.isArray(value.options)) return undefined;
262
+ const id = requiredResponseText(value.id, MAX_RESPONSE_IDENTIFIER_CHARS);
263
+ const prompt = requiredResponseText(value.prompt, MAX_RESPONSE_TEXT_CHARS);
264
+ const description = optionalResponseText(value.description);
265
+ if (!id || !RESPONSE_IDENTIFIER.test(id)) return undefined;
266
+ if (seenIds.has(id) || !prompt || description === null) return undefined;
267
+ if (value.options.length < 2 || value.options.length > MAX_RESPONSE_OPTIONS) return undefined;
268
+
269
+ seenIds.add(id);
270
+ const seenValues = new Set<string>();
271
+ const options: ChoiceResponseOption[] = [];
272
+ for (const rawOption of value.options) {
273
+ const option = parseChoiceResponseOption(rawOption, seenValues);
274
+ if (!option) return undefined;
275
+ options.push(option);
276
+ }
277
+ return { id, prompt, description, options };
278
+ }
279
+
280
+ export function parseChoiceResponseSpec(value: unknown): ChoiceResponseSpec | undefined {
281
+ if (!isObject(value) || value.kind !== "choice-form" || !Array.isArray(value.fields)) {
282
+ return undefined;
283
+ }
284
+ if (value.fields.length === 0 || value.fields.length > MAX_RESPONSE_FIELDS) {
285
+ return undefined;
286
+ }
287
+ const seenIds = new Set<string>();
288
+ const fields: ChoiceResponseField[] = [];
289
+ for (const rawField of value.fields) {
290
+ const field = parseChoiceResponseField(rawField, seenIds);
291
+ if (!field) return undefined;
292
+ fields.push(field);
293
+ }
294
+ return { kind: "choice-form", fields };
295
+ }
296
+
214
297
  function parsePendingQuestion(value: unknown): PendingQuestion | undefined {
215
298
  if (!isObject(value)) return undefined;
216
299
  if (
217
300
  typeof value.id !== "string" ||
218
301
  typeof value.question !== "string" ||
219
- (value.status !== "needs-evidence" && value.status !== "blocked") ||
302
+ (value.status !== "open" && value.status !== "blocked" && value.status !== "needs-evidence") ||
220
303
  typeof value.sourceRouteId !== "string"
221
304
  ) {
222
305
  return undefined;
223
306
  }
307
+ const legacyBlocked = value.status === "blocked";
308
+ let resolutionOwner: QuestionResolutionOwner = legacyBlocked ? "unknown" : "agent";
309
+ if (isQuestionResolutionOwner(value.resolutionOwner)) resolutionOwner = value.resolutionOwner;
310
+ let gate: QuestionGate = legacyBlocked ? "before-completion" : "none";
311
+ if (isQuestionGate(value.gate)) gate = value.gate;
312
+ const resolutionCriteria =
313
+ typeof value.resolutionCriteria === "string"
314
+ ? value.resolutionCriteria
315
+ : `Obtain evidence that settles: ${value.question}`;
316
+ const context = typeof value.context === "string" ? value.context.trim() || undefined : undefined;
317
+ const responseSpec =
318
+ resolutionOwner === "user" ? parseChoiceResponseSpec(value.responseSpec) : undefined;
224
319
  return {
225
320
  id: value.id,
226
321
  question: value.question,
227
- status: value.status,
322
+ context,
323
+ responseSpec,
324
+ status: legacyBlocked ? "blocked" : "open",
325
+ resolutionOwner,
326
+ gate,
327
+ resolutionCriteria,
228
328
  sourceRouteId: value.sourceRouteId,
229
329
  };
230
330
  }
231
331
 
332
+ function parseQuestionUpdate(value: unknown): QuestionUpdate | undefined {
333
+ if (
334
+ !isObject(value) ||
335
+ typeof value.questionId !== "string" ||
336
+ !isQuestionUpdateStatus(value.status) ||
337
+ typeof value.result !== "string" ||
338
+ !isStringArray(value.basis)
339
+ ) {
340
+ return undefined;
341
+ }
342
+ return {
343
+ questionId: value.questionId,
344
+ status: value.status,
345
+ result: value.result,
346
+ basis: value.basis,
347
+ };
348
+ }
349
+
232
350
  export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefined {
233
351
  if (!isObject(value)) return undefined;
234
352
  if (
235
353
  value.protocol !== PROTOCOL &&
236
354
  value.protocol !== PREVIOUS_PROTOCOL &&
355
+ value.protocol !== OLDER_PROTOCOL &&
237
356
  value.protocol !== LEGACY_PROTOCOL
238
- ) return undefined;
357
+ )
358
+ return undefined;
239
359
 
240
360
  if (value.kind === "mode") {
241
361
  if (!isMode(value.mode)) return undefined;
@@ -243,7 +363,11 @@ export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefi
243
363
  }
244
364
 
245
365
  if (value.kind === "focus") {
246
- if (value.protocol !== PROTOCOL || typeof value.questionId !== "string") return undefined;
366
+ if (
367
+ (value.protocol !== PROTOCOL && value.protocol !== PREVIOUS_PROTOCOL) ||
368
+ typeof value.questionId !== "string"
369
+ )
370
+ return undefined;
247
371
  return { protocol: PROTOCOL, kind: "focus", questionId: value.questionId };
248
372
  }
249
373
 
@@ -254,6 +378,8 @@ export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefi
254
378
  typeof value.owner !== "string" ||
255
379
  typeof value.reason !== "string" ||
256
380
  !isStringArray(value.knownEvidence) ||
381
+ (value.consideredAlternatives !== undefined &&
382
+ !Array.isArray(value.consideredAlternatives)) ||
257
383
  (value.targetQuestionId !== undefined && typeof value.targetQuestionId !== "string") ||
258
384
  (value.methodLocation !== undefined && typeof value.methodLocation !== "string") ||
259
385
  (value.executionProfile !== undefined && !isDirectExecutionProfile(value.executionProfile)) ||
@@ -261,6 +387,10 @@ export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefi
261
387
  ) {
262
388
  return undefined;
263
389
  }
390
+ const consideredAlternatives = Array.isArray(value.consideredAlternatives)
391
+ ? value.consideredAlternatives.map(parseRouteAlternative)
392
+ : [];
393
+ if (consideredAlternatives.some((alternative) => !alternative)) return undefined;
264
394
  return {
265
395
  protocol: PROTOCOL,
266
396
  kind: "route",
@@ -269,6 +399,7 @@ export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefi
269
399
  owner: value.owner,
270
400
  reason: value.reason,
271
401
  knownEvidence: value.knownEvidence,
402
+ consideredAlternatives: consideredAlternatives as RouteAlternative[],
272
403
  targetQuestionId: value.targetQuestionId,
273
404
  methodLocation: value.methodLocation,
274
405
  executionProfile: value.executionProfile,
@@ -303,9 +434,13 @@ export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefi
303
434
  openedQuestions: value.openQuestions.map((question, index) => ({
304
435
  id: `question:${value.routeId}:legacy:${index + 1}`,
305
436
  question,
306
- status: "needs-evidence",
437
+ status: "open",
438
+ resolutionOwner: "agent",
439
+ gate: "none",
440
+ resolutionCriteria: `Obtain evidence that settles: ${question}`,
307
441
  sourceRouteId: String(value.routeId),
308
442
  })),
443
+ questionUpdates: [],
309
444
  artifacts: value.artifacts,
310
445
  changedArtifacts: false,
311
446
  };
@@ -314,6 +449,12 @@ export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefi
314
449
  if (!Array.isArray(value.openedQuestions)) return undefined;
315
450
  const openedQuestions = value.openedQuestions.map(parsePendingQuestion);
316
451
  if (openedQuestions.some((question) => !question)) return undefined;
452
+ if (value.questionUpdates !== undefined && !Array.isArray(value.questionUpdates))
453
+ return undefined;
454
+ const questionUpdates = Array.isArray(value.questionUpdates)
455
+ ? value.questionUpdates.map(parseQuestionUpdate)
456
+ : [];
457
+ if (questionUpdates.some((update) => !update)) return undefined;
317
458
  return {
318
459
  protocol: PROTOCOL,
319
460
  kind: "judgment",
@@ -324,6 +465,7 @@ export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefi
324
465
  result: value.result,
325
466
  basis: value.basis,
326
467
  openedQuestions: openedQuestions as PendingQuestion[],
468
+ questionUpdates: questionUpdates as QuestionUpdate[],
327
469
  artifacts: value.artifacts,
328
470
  changedArtifacts: typeof value.changedArtifacts === "boolean" ? value.changedArtifacts : false,
329
471
  };
@@ -1,6 +1,12 @@
1
1
  import type { DeveloperMode } from "./state.ts";
2
2
 
3
- const MUTATION_TOOL_NAMES = new Set(["edit", "write", "bash"]);
3
+ export type ControlledToolCapability = "execute" | "mutate";
4
+
5
+ const CONTROLLED_BUILTIN_CAPABILITIES = new Map<string, ControlledToolCapability>([
6
+ ["bash", "execute"],
7
+ ["edit", "mutate"],
8
+ ["write", "mutate"],
9
+ ]);
4
10
 
5
11
  export interface ToolMetadataLike {
6
12
  name: string;
@@ -16,46 +22,69 @@ export interface ToolPolicyResult {
16
22
  memory: ToolPolicyMemory;
17
23
  }
18
24
 
19
- export function builtinMutationToolNames(tools: ToolMetadataLike[]): Set<string> {
20
- return new Set(
21
- tools
22
- .filter((tool) => tool.sourceInfo.source === "builtin" && MUTATION_TOOL_NAMES.has(tool.name))
23
- .map((tool) => tool.name),
24
- );
25
+ export interface ProtocolToolAccess {
26
+ canExecute: boolean;
27
+ canMutate: boolean;
28
+ hasBeforeDirectGate: boolean;
29
+ }
30
+
31
+ export function builtinControlledToolCapabilities(
32
+ tools: ToolMetadataLike[],
33
+ ): Map<string, ControlledToolCapability> {
34
+ const result = new Map<string, ControlledToolCapability>();
35
+ for (const tool of tools) {
36
+ if (tool.sourceInfo.source !== "builtin") continue;
37
+ const capability = CONTROLLED_BUILTIN_CAPABILITIES.get(tool.name);
38
+ if (capability) result.set(tool.name, capability);
39
+ }
40
+ return result;
41
+ }
42
+
43
+ export function isControlledToolAllowed(input: {
44
+ mode: DeveloperMode;
45
+ capability: ControlledToolCapability;
46
+ access: ProtocolToolAccess;
47
+ }): boolean {
48
+ if (input.mode === "off") return true;
49
+
50
+ if (input.access.hasBeforeDirectGate) {
51
+ if (input.capability === "mutate") return false;
52
+ return input.access.canExecute && !input.access.canMutate;
53
+ }
54
+
55
+ if (input.mode === "on") return true;
56
+ if (input.capability === "execute") return input.access.canExecute;
57
+ return input.access.canMutate;
25
58
  }
26
59
 
27
60
  export function reconcileProtocolTools(input: {
28
61
  activeTools: string[];
29
62
  allTools: ToolMetadataLike[];
30
63
  mode: DeveloperMode;
31
- directRouteOpen: boolean;
64
+ access: ProtocolToolAccess;
32
65
  protocolTools: readonly string[];
33
66
  memory: ToolPolicyMemory;
34
67
  }): ToolPolicyResult {
35
68
  const active = new Set(input.activeTools);
36
- const mutationBuiltins = builtinMutationToolNames(input.allTools);
69
+ const controlledBuiltins = builtinControlledToolCapabilities(input.allTools);
37
70
  const withheld = new Set(
38
- [...input.memory.withheldBuiltins].filter((name) => mutationBuiltins.has(name)),
71
+ [...input.memory.withheldBuiltins].filter((name) => controlledBuiltins.has(name)),
39
72
  );
40
73
 
41
- if (input.mode === "strict") {
74
+ if (input.mode === "off") {
75
+ for (const name of withheld) active.add(name);
76
+ withheld.clear();
77
+ for (const tool of input.protocolTools) active.delete(tool);
78
+ } else {
42
79
  for (const tool of input.protocolTools) active.add(tool);
43
-
44
- if (input.directRouteOpen) {
45
- for (const tool of withheld) active.add(tool);
46
- } else {
47
- for (const tool of mutationBuiltins) {
48
- if (active.delete(tool)) withheld.add(tool);
80
+ for (const [name, capability] of controlledBuiltins) {
81
+ const allowed = isControlledToolAllowed({ mode: input.mode, capability, access: input.access });
82
+ if (allowed) {
83
+ if (withheld.delete(name)) active.add(name);
84
+ } else if (active.delete(name)) {
85
+ withheld.add(name);
49
86
  }
50
87
  }
51
- } else {
52
- for (const tool of withheld) active.add(tool);
53
- withheld.clear();
54
-
55
- for (const tool of input.protocolTools) {
56
- if (input.mode === "on") active.add(tool);
57
- else active.delete(tool);
58
- }
59
88
  }
60
89
 
61
90
  return {