@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.
@@ -0,0 +1,344 @@
1
+ import { assign, setup, transition, type StateValue } from "xstate";
2
+
3
+ import type {
4
+ DeveloperEvent,
5
+ DeveloperState,
6
+ FocusEvent,
7
+ JudgmentEvent,
8
+ ModeEvent,
9
+ PendingQuestion,
10
+ RouteEvent,
11
+ } from "./state.ts";
12
+
13
+ type DeveloperMachineEvent =
14
+ | { type: "MODE"; event: ModeEvent }
15
+ | { type: "FOCUS"; event: FocusEvent }
16
+ | { type: "ROUTE"; event: RouteEvent }
17
+ | { type: "JUDGMENT"; event: JudgmentEvent };
18
+
19
+ export type DeveloperMachineTag =
20
+ | "execute"
21
+ | "mutate"
22
+ | "blocks-direct"
23
+ | "blocks-completion"
24
+ | "reroute-required"
25
+ | "framing-required"
26
+ | "verification-required";
27
+
28
+ export const initialState = (): DeveloperState => ({
29
+ mode: "off",
30
+ routeHistory: [],
31
+ judgmentHistory: [],
32
+ pendingQuestions: [],
33
+ rerouteRequired: false,
34
+ implementationFramingRequired: false,
35
+ verificationRequired: false,
36
+ });
37
+
38
+ function normalizedQuestion(value: string): string {
39
+ return value.toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
40
+ }
41
+
42
+ function upsertQuestion(questions: PendingQuestion[], next: PendingQuestion): PendingQuestion[] {
43
+ const nextKey = normalizedQuestion(next.question);
44
+ const existingIndex = questions.findIndex(
45
+ (question) => question.id === next.id || normalizedQuestion(question.question) === nextKey,
46
+ );
47
+ if (existingIndex === -1) return [...questions, next];
48
+
49
+ const existing = questions[existingIndex];
50
+ if (!existing) return [...questions, next];
51
+ const updated = [...questions];
52
+ updated[existingIndex] = { ...next, id: existing.id };
53
+ return updated;
54
+ }
55
+
56
+ function applyRouteContext(state: DeveloperState, event: RouteEvent): DeveloperState {
57
+ return {
58
+ ...state,
59
+ activeRoute: event,
60
+ lastRoute: event,
61
+ routeHistory: [...state.routeHistory, event],
62
+ rerouteRequired: false,
63
+ focusedQuestionId:
64
+ event.targetQuestionId === state.focusedQuestionId ? undefined : state.focusedQuestionId,
65
+ };
66
+ }
67
+
68
+ function questionsAfterJudgment(
69
+ state: DeveloperState,
70
+ route: RouteEvent,
71
+ event: JudgmentEvent,
72
+ ): PendingQuestion[] {
73
+ let pending = [...state.pendingQuestions];
74
+ const remainsOpen = event.status === "needs-evidence" || event.status === "blocked";
75
+ if (!route.targetQuestionId && remainsOpen && event.openedQuestions.length === 0) {
76
+ pending = upsertQuestion(pending, {
77
+ id: `question:${route.routeId}`,
78
+ question: route.question,
79
+ status: event.status === "blocked" ? "blocked" : "open",
80
+ resolutionOwner: "unknown",
81
+ gate: event.status === "blocked" ? "before-completion" : "none",
82
+ resolutionCriteria: `Obtain evidence that settles: ${route.question}`,
83
+ sourceRouteId: route.routeId,
84
+ });
85
+ }
86
+
87
+ for (const question of event.openedQuestions) pending = upsertQuestion(pending, question);
88
+ for (const update of event.questionUpdates) {
89
+ if (update.status === "resolved" || update.status === "not-applicable") {
90
+ pending = pending.filter((question) => question.id !== update.questionId);
91
+ continue;
92
+ }
93
+ const existing = pending.find((question) => question.id === update.questionId);
94
+ if (!existing) continue;
95
+ pending = upsertQuestion(pending, {
96
+ ...existing,
97
+ status: update.status,
98
+ sourceRouteId: route.routeId,
99
+ });
100
+ }
101
+ return pending;
102
+ }
103
+
104
+ function framingAfterJudgment(state: DeveloperState, route: RouteEvent, event: JudgmentEvent): boolean {
105
+ if (route.owner === "model" && event.status === "resolved") return true;
106
+ const closesGate =
107
+ (route.owner === "sketch" || route.owner === "signal") &&
108
+ (event.status === "resolved" || event.status === "not-applicable");
109
+ return closesGate ? false : state.implementationFramingRequired;
110
+ }
111
+
112
+ function verificationAfterJudgment(
113
+ state: DeveloperState,
114
+ route: RouteEvent,
115
+ event: JudgmentEvent,
116
+ pending: PendingQuestion[],
117
+ ): boolean {
118
+ if (route.owner === "direct" && event.changedArtifacts) return true;
119
+ const completionQuestionRemains = pending.some(
120
+ (question) => question.gate === "before-completion" || question.gate === "before-direct",
121
+ );
122
+ if (route.owner === "verify" && event.status === "resolved" && !completionQuestionRemains) {
123
+ return false;
124
+ }
125
+ return state.verificationRequired;
126
+ }
127
+
128
+ function applyJudgmentContext(state: DeveloperState, event: JudgmentEvent): DeveloperState {
129
+ const route = state.activeRoute;
130
+ if (!route) return state;
131
+ const judgment = { ...event, question: route.question, owner: route.owner };
132
+ const pending = questionsAfterJudgment(state, route, event);
133
+ return {
134
+ ...state,
135
+ activeRoute: undefined,
136
+ lastJudgment: judgment,
137
+ judgmentHistory: [...state.judgmentHistory, judgment],
138
+ pendingQuestions: pending,
139
+ focusedQuestionId: pending.some((question) => question.id === state.focusedQuestionId)
140
+ ? state.focusedQuestionId
141
+ : undefined,
142
+ rerouteRequired: route.owner === "direct",
143
+ implementationFramingRequired: framingAfterJudgment(state, route, event),
144
+ verificationRequired: verificationAfterJudgment(state, route, event, pending),
145
+ };
146
+ }
147
+
148
+ function assertNever(value: never): never {
149
+ throw new Error(`Unexpected Developer machine event: ${JSON.stringify(value)}`);
150
+ }
151
+
152
+ function reduceDeveloperContext(state: DeveloperState, event: DeveloperEvent): DeveloperState {
153
+ switch (event.kind) {
154
+ case "mode":
155
+ return event.mode === "off" ? initialState() : { ...state, mode: event.mode };
156
+ case "focus":
157
+ return { ...state, focusedQuestionId: event.questionId };
158
+ case "route":
159
+ return applyRouteContext(state, event);
160
+ case "judgment":
161
+ return applyJudgmentContext(state, event);
162
+ default:
163
+ return assertNever(event);
164
+ }
165
+ }
166
+
167
+ function machineEvent(event: DeveloperEvent): DeveloperMachineEvent {
168
+ switch (event.kind) {
169
+ case "mode":
170
+ return { type: "MODE", event };
171
+ case "focus":
172
+ return { type: "FOCUS", event };
173
+ case "route":
174
+ return { type: "ROUTE", event };
175
+ case "judgment":
176
+ return { type: "JUDGMENT", event };
177
+ default:
178
+ return assertNever(event);
179
+ }
180
+ }
181
+
182
+ function canApplyEvent(context: DeveloperState, event: DeveloperMachineEvent): boolean {
183
+ switch (event.type) {
184
+ case "MODE":
185
+ return true;
186
+ case "FOCUS":
187
+ return context.pendingQuestions.some((question) => question.id === event.event.questionId);
188
+ case "ROUTE":
189
+ return (
190
+ !context.activeRoute &&
191
+ (event.event.owner !== "direct" ||
192
+ !context.pendingQuestions.some((question) => question.gate === "before-direct"))
193
+ );
194
+ case "JUDGMENT":
195
+ return context.activeRoute?.routeId === event.event.routeId;
196
+ default:
197
+ return assertNever(event);
198
+ }
199
+ }
200
+
201
+ const machineSetup = setup({
202
+ types: {
203
+ context: {} as DeveloperState,
204
+ events: {} as DeveloperMachineEvent,
205
+ tags: {} as DeveloperMachineTag,
206
+ },
207
+ actions: {
208
+ applyEvent: assign(({ context, event }) => reduceDeveloperContext(context, event.event)),
209
+ },
210
+ guards: {
211
+ eventAllowed: ({ context, event }) => canApplyEvent(context, event),
212
+ modeOff: ({ context }) => context.mode === "off",
213
+ modeOn: ({ context }) => context.mode === "on",
214
+ modeStrict: ({ context }) => context.mode === "strict",
215
+ routeIdle: ({ context }) => !context.activeRoute,
216
+ routeJudgment: ({ context }) => Boolean(context.activeRoute && context.activeRoute.owner !== "direct"),
217
+ routeDirect: ({ context }) => context.activeRoute?.owner === "direct",
218
+ questionsClear: ({ context }) => context.pendingQuestions.length === 0,
219
+ questionsOpen: ({ context }) => context.pendingQuestions.length > 0,
220
+ directGateClear: ({ context }) =>
221
+ !context.pendingQuestions.some((question) => question.gate === "before-direct"),
222
+ directGateBlocked: ({ context }) =>
223
+ context.pendingQuestions.some((question) => question.gate === "before-direct"),
224
+ completionGateClear: ({ context }) =>
225
+ !context.pendingQuestions.some(
226
+ (question) => question.gate === "before-direct" || question.gate === "before-completion",
227
+ ),
228
+ completionGateBlocked: ({ context }) =>
229
+ context.pendingQuestions.some(
230
+ (question) => question.gate === "before-direct" || question.gate === "before-completion",
231
+ ),
232
+ checkpointReady: ({ context }) => !context.rerouteRequired,
233
+ checkpointRequired: ({ context }) => context.rerouteRequired,
234
+ framingClear: ({ context }) => !context.implementationFramingRequired,
235
+ framingRequired: ({ context }) => context.implementationFramingRequired,
236
+ verificationCurrent: ({ context }) => !context.verificationRequired,
237
+ verificationRequired: ({ context }) => context.verificationRequired,
238
+ },
239
+ });
240
+
241
+ export const developerMachine = machineSetup.createMachine({
242
+ id: "developer",
243
+ type: "parallel",
244
+ context: initialState(),
245
+ on: {
246
+ MODE: { guard: "eventAllowed", actions: "applyEvent" },
247
+ FOCUS: { guard: "eventAllowed", actions: "applyEvent" },
248
+ ROUTE: { guard: "eventAllowed", actions: "applyEvent" },
249
+ JUDGMENT: { guard: "eventAllowed", actions: "applyEvent" },
250
+ },
251
+ states: {
252
+ mode: {
253
+ initial: "off",
254
+ states: {
255
+ off: { always: [{ guard: "modeOn", target: "on" }, { guard: "modeStrict", target: "strict" }] },
256
+ on: { always: [{ guard: "modeOff", target: "off" }, { guard: "modeStrict", target: "strict" }] },
257
+ strict: { always: [{ guard: "modeOff", target: "off" }, { guard: "modeOn", target: "on" }] },
258
+ },
259
+ },
260
+ route: {
261
+ initial: "idle",
262
+ states: {
263
+ idle: { always: [{ guard: "routeDirect", target: "direct" }, { guard: "routeJudgment", target: "judgment" }] },
264
+ judgment: { tags: "execute", always: [{ guard: "routeIdle", target: "idle" }, { guard: "routeDirect", target: "direct" }] },
265
+ direct: { tags: ["execute", "mutate"], always: [{ guard: "routeIdle", target: "idle" }, { guard: "routeJudgment", target: "judgment" }] },
266
+ },
267
+ },
268
+ questions: {
269
+ initial: "clear",
270
+ states: {
271
+ clear: { always: { guard: "questionsOpen", target: "open" } },
272
+ open: { always: { guard: "questionsClear", target: "clear" } },
273
+ },
274
+ },
275
+ directGate: {
276
+ initial: "clear",
277
+ states: {
278
+ clear: { always: { guard: "directGateBlocked", target: "blocked" } },
279
+ blocked: { tags: "blocks-direct", always: { guard: "directGateClear", target: "clear" } },
280
+ },
281
+ },
282
+ completionGate: {
283
+ initial: "clear",
284
+ states: {
285
+ clear: { always: { guard: "completionGateBlocked", target: "blocked" } },
286
+ blocked: { tags: "blocks-completion", always: { guard: "completionGateClear", target: "clear" } },
287
+ },
288
+ },
289
+ checkpoint: {
290
+ initial: "ready",
291
+ states: {
292
+ ready: { always: { guard: "checkpointRequired", target: "required" } },
293
+ required: { tags: "reroute-required", always: { guard: "checkpointReady", target: "ready" } },
294
+ },
295
+ },
296
+ framing: {
297
+ initial: "clear",
298
+ states: {
299
+ clear: { always: { guard: "framingRequired", target: "required" } },
300
+ required: { tags: "framing-required", always: { guard: "framingClear", target: "clear" } },
301
+ },
302
+ },
303
+ verification: {
304
+ initial: "current",
305
+ states: {
306
+ current: { always: { guard: "verificationRequired", target: "required" } },
307
+ required: { tags: "verification-required", always: { guard: "verificationCurrent", target: "current" } },
308
+ },
309
+ },
310
+ },
311
+ });
312
+
313
+ function machineValue(state: DeveloperState): StateValue {
314
+ let route = "idle";
315
+ if (state.activeRoute?.owner === "direct") route = "direct";
316
+ else if (state.activeRoute) route = "judgment";
317
+ const directBlocked = state.pendingQuestions.some((question) => question.gate === "before-direct");
318
+ const completionBlocked = state.pendingQuestions.some(
319
+ (question) => question.gate === "before-direct" || question.gate === "before-completion",
320
+ );
321
+ return {
322
+ mode: state.mode,
323
+ route,
324
+ questions: state.pendingQuestions.length > 0 ? "open" : "clear",
325
+ directGate: directBlocked ? "blocked" : "clear",
326
+ completionGate: completionBlocked ? "blocked" : "clear",
327
+ checkpoint: state.rerouteRequired ? "required" : "ready",
328
+ framing: state.implementationFramingRequired ? "required" : "clear",
329
+ verification: state.verificationRequired ? "required" : "current",
330
+ };
331
+ }
332
+
333
+ export function developerSnapshot(state: DeveloperState) {
334
+ return developerMachine.resolveState({ value: machineValue(state), context: state });
335
+ }
336
+
337
+ export function canApplyDeveloperEvent(state: DeveloperState, event: DeveloperEvent): boolean {
338
+ return developerSnapshot(state).can(machineEvent(event));
339
+ }
340
+
341
+ export function applyDeveloperEvent(state: DeveloperState, event: DeveloperEvent): DeveloperState {
342
+ const [nextSnapshot] = transition(developerMachine, developerSnapshot(state), machineEvent(event));
343
+ return nextSnapshot.context;
344
+ }