@hobin/developer 0.1.4 → 0.1.5

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;
@@ -10,7 +21,10 @@ export const LEGACY_JUDGMENT_TOOL = "record_judgment" as const;
10
21
 
11
22
  export type DeveloperMode = "off" | "on" | "strict";
12
23
  export type JudgmentStatus = "resolved" | "needs-evidence" | "not-applicable" | "blocked";
13
- export type PendingQuestionStatus = "needs-evidence" | "blocked";
24
+ export type PendingQuestionStatus = "open" | "blocked";
25
+ export type QuestionResolutionOwner = "agent" | "user" | "environment" | "unknown";
26
+ export type QuestionGate = "none" | "before-direct" | "before-completion";
27
+ export type QuestionUpdateStatus = "resolved" | "not-applicable" | "open" | "blocked";
14
28
  export type DirectExecutionProfile = "ordinary" | "behavior-preserving-structure";
15
29
 
16
30
  export interface ModeEvent {
@@ -31,6 +45,11 @@ export interface DirectStepContract {
31
45
  verification: string;
32
46
  }
33
47
 
48
+ export interface RouteAlternative {
49
+ owner: string;
50
+ reason: string;
51
+ }
52
+
34
53
  export interface RouteEvent {
35
54
  protocol: typeof PROTOCOL;
36
55
  kind: "route";
@@ -39,6 +58,7 @@ export interface RouteEvent {
39
58
  owner: string;
40
59
  reason: string;
41
60
  knownEvidence: string[];
61
+ consideredAlternatives: RouteAlternative[];
42
62
  targetQuestionId?: string;
43
63
  methodLocation?: string;
44
64
  executionProfile?: DirectExecutionProfile;
@@ -49,9 +69,19 @@ export interface PendingQuestion {
49
69
  id: string;
50
70
  question: string;
51
71
  status: PendingQuestionStatus;
72
+ resolutionOwner: QuestionResolutionOwner;
73
+ gate: QuestionGate;
74
+ resolutionCriteria: string;
52
75
  sourceRouteId: string;
53
76
  }
54
77
 
78
+ export interface QuestionUpdate {
79
+ questionId: string;
80
+ status: QuestionUpdateStatus;
81
+ result: string;
82
+ basis: string[];
83
+ }
84
+
55
85
  export interface JudgmentEvent {
56
86
  protocol: typeof PROTOCOL;
57
87
  kind: "judgment";
@@ -62,6 +92,7 @@ export interface JudgmentEvent {
62
92
  result: string;
63
93
  basis: string[];
64
94
  openedQuestions: PendingQuestion[];
95
+ questionUpdates: QuestionUpdate[];
65
96
  artifacts: string[];
66
97
  changedArtifacts: boolean;
67
98
  }
@@ -73,108 +104,38 @@ export interface DeveloperState {
73
104
  activeRoute?: RouteEvent;
74
105
  lastRoute?: RouteEvent;
75
106
  lastJudgment?: JudgmentEvent;
107
+ routeHistory: RouteEvent[];
108
+ judgmentHistory: JudgmentEvent[];
76
109
  pendingQuestions: PendingQuestion[];
77
110
  focusedQuestionId?: string;
111
+ rerouteRequired: boolean;
78
112
  implementationFramingRequired: boolean;
79
113
  verificationRequired: boolean;
80
114
  }
81
115
 
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";
116
+ export type ProtocolState =
117
+ | "idle"
118
+ | "needs-judgment"
119
+ | "needs-evidence"
120
+ | "needs-answer"
121
+ | "needs-routing"
122
+ | "needs-verification"
123
+ | "blocked";
90
124
 
91
125
  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";
126
+ const snapshot = developerSnapshot(state);
127
+ if (!snapshot.matches({ route: "idle" })) return "needs-judgment";
128
+ if (
129
+ snapshot.hasTag("blocks-direct") ||
130
+ state.pendingQuestions.some((question) => question.status === "blocked")
131
+ ) return "blocked";
132
+ if (state.pendingQuestions.some((question) => question.resolutionOwner === "user")) return "needs-answer";
133
+ if (snapshot.matches({ questions: "open" })) return "needs-evidence";
134
+ if (snapshot.hasTag("reroute-required")) return "needs-routing";
135
+ if (snapshot.hasTag("verification-required")) return "needs-verification";
96
136
  return "idle";
97
137
  }
98
138
 
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
139
  function isObject(value: unknown): value is Record<string, unknown> {
179
140
  return Boolean(value) && typeof value === "object";
180
141
  }
@@ -211,29 +172,79 @@ function parseDirectStep(value: unknown): DirectStepContract | undefined {
211
172
  };
212
173
  }
213
174
 
175
+ function parseRouteAlternative(value: unknown): RouteAlternative | undefined {
176
+ if (!isObject(value) || typeof value.owner !== "string" || typeof value.reason !== "string") {
177
+ return undefined;
178
+ }
179
+ return { owner: value.owner, reason: value.reason };
180
+ }
181
+
182
+ function isQuestionResolutionOwner(value: unknown): value is QuestionResolutionOwner {
183
+ return value === "agent" || value === "user" || value === "environment" || value === "unknown";
184
+ }
185
+
186
+ function isQuestionGate(value: unknown): value is QuestionGate {
187
+ return value === "none" || value === "before-direct" || value === "before-completion";
188
+ }
189
+
190
+ function isQuestionUpdateStatus(value: unknown): value is QuestionUpdateStatus {
191
+ return value === "resolved" || value === "not-applicable" || value === "open" || value === "blocked";
192
+ }
193
+
214
194
  function parsePendingQuestion(value: unknown): PendingQuestion | undefined {
215
195
  if (!isObject(value)) return undefined;
216
196
  if (
217
197
  typeof value.id !== "string" ||
218
198
  typeof value.question !== "string" ||
219
- (value.status !== "needs-evidence" && value.status !== "blocked") ||
199
+ (value.status !== "open" && value.status !== "blocked" && value.status !== "needs-evidence") ||
220
200
  typeof value.sourceRouteId !== "string"
221
201
  ) {
222
202
  return undefined;
223
203
  }
204
+ const legacyBlocked = value.status === "blocked";
205
+ let resolutionOwner: QuestionResolutionOwner = legacyBlocked ? "unknown" : "agent";
206
+ if (isQuestionResolutionOwner(value.resolutionOwner)) resolutionOwner = value.resolutionOwner;
207
+ let gate: QuestionGate = legacyBlocked ? "before-completion" : "none";
208
+ if (isQuestionGate(value.gate)) gate = value.gate;
209
+ const resolutionCriteria =
210
+ typeof value.resolutionCriteria === "string"
211
+ ? value.resolutionCriteria
212
+ : `Obtain evidence that settles: ${value.question}`;
224
213
  return {
225
214
  id: value.id,
226
215
  question: value.question,
227
- status: value.status,
216
+ status: legacyBlocked ? "blocked" : "open",
217
+ resolutionOwner,
218
+ gate,
219
+ resolutionCriteria,
228
220
  sourceRouteId: value.sourceRouteId,
229
221
  };
230
222
  }
231
223
 
224
+ function parseQuestionUpdate(value: unknown): QuestionUpdate | undefined {
225
+ if (
226
+ !isObject(value) ||
227
+ typeof value.questionId !== "string" ||
228
+ !isQuestionUpdateStatus(value.status) ||
229
+ typeof value.result !== "string" ||
230
+ !isStringArray(value.basis)
231
+ ) {
232
+ return undefined;
233
+ }
234
+ return {
235
+ questionId: value.questionId,
236
+ status: value.status,
237
+ result: value.result,
238
+ basis: value.basis,
239
+ };
240
+ }
241
+
232
242
  export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefined {
233
243
  if (!isObject(value)) return undefined;
234
244
  if (
235
245
  value.protocol !== PROTOCOL &&
236
246
  value.protocol !== PREVIOUS_PROTOCOL &&
247
+ value.protocol !== OLDER_PROTOCOL &&
237
248
  value.protocol !== LEGACY_PROTOCOL
238
249
  ) return undefined;
239
250
 
@@ -243,7 +254,10 @@ export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefi
243
254
  }
244
255
 
245
256
  if (value.kind === "focus") {
246
- if (value.protocol !== PROTOCOL || typeof value.questionId !== "string") return undefined;
257
+ if (
258
+ (value.protocol !== PROTOCOL && value.protocol !== PREVIOUS_PROTOCOL) ||
259
+ typeof value.questionId !== "string"
260
+ ) return undefined;
247
261
  return { protocol: PROTOCOL, kind: "focus", questionId: value.questionId };
248
262
  }
249
263
 
@@ -254,6 +268,7 @@ export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefi
254
268
  typeof value.owner !== "string" ||
255
269
  typeof value.reason !== "string" ||
256
270
  !isStringArray(value.knownEvidence) ||
271
+ (value.consideredAlternatives !== undefined && !Array.isArray(value.consideredAlternatives)) ||
257
272
  (value.targetQuestionId !== undefined && typeof value.targetQuestionId !== "string") ||
258
273
  (value.methodLocation !== undefined && typeof value.methodLocation !== "string") ||
259
274
  (value.executionProfile !== undefined && !isDirectExecutionProfile(value.executionProfile)) ||
@@ -261,6 +276,10 @@ export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefi
261
276
  ) {
262
277
  return undefined;
263
278
  }
279
+ const consideredAlternatives = Array.isArray(value.consideredAlternatives)
280
+ ? value.consideredAlternatives.map(parseRouteAlternative)
281
+ : [];
282
+ if (consideredAlternatives.some((alternative) => !alternative)) return undefined;
264
283
  return {
265
284
  protocol: PROTOCOL,
266
285
  kind: "route",
@@ -269,6 +288,7 @@ export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefi
269
288
  owner: value.owner,
270
289
  reason: value.reason,
271
290
  knownEvidence: value.knownEvidence,
291
+ consideredAlternatives: consideredAlternatives as RouteAlternative[],
272
292
  targetQuestionId: value.targetQuestionId,
273
293
  methodLocation: value.methodLocation,
274
294
  executionProfile: value.executionProfile,
@@ -303,9 +323,13 @@ export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefi
303
323
  openedQuestions: value.openQuestions.map((question, index) => ({
304
324
  id: `question:${value.routeId}:legacy:${index + 1}`,
305
325
  question,
306
- status: "needs-evidence",
326
+ status: "open",
327
+ resolutionOwner: "agent",
328
+ gate: "none",
329
+ resolutionCriteria: `Obtain evidence that settles: ${question}`,
307
330
  sourceRouteId: String(value.routeId),
308
331
  })),
332
+ questionUpdates: [],
309
333
  artifacts: value.artifacts,
310
334
  changedArtifacts: false,
311
335
  };
@@ -314,6 +338,11 @@ export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefi
314
338
  if (!Array.isArray(value.openedQuestions)) return undefined;
315
339
  const openedQuestions = value.openedQuestions.map(parsePendingQuestion);
316
340
  if (openedQuestions.some((question) => !question)) return undefined;
341
+ if (value.questionUpdates !== undefined && !Array.isArray(value.questionUpdates)) return undefined;
342
+ const questionUpdates = Array.isArray(value.questionUpdates)
343
+ ? value.questionUpdates.map(parseQuestionUpdate)
344
+ : [];
345
+ if (questionUpdates.some((update) => !update)) return undefined;
317
346
  return {
318
347
  protocol: PROTOCOL,
319
348
  kind: "judgment",
@@ -324,6 +353,7 @@ export function normalizeDeveloperEvent(value: unknown): DeveloperEvent | undefi
324
353
  result: value.result,
325
354
  basis: value.basis,
326
355
  openedQuestions: openedQuestions as PendingQuestion[],
356
+ questionUpdates: questionUpdates as QuestionUpdate[],
327
357
  artifacts: value.artifacts,
328
358
  changedArtifacts: typeof value.changedArtifacts === "boolean" ? value.changedArtifacts : false,
329
359
  };
@@ -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 {