@trevonistrevon/pi-loop 0.5.0 → 0.5.1

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.
Files changed (54) hide show
  1. package/README.md +11 -10
  2. package/dist/commands/loop-command.d.ts +22 -0
  3. package/dist/commands/loop-command.js +148 -0
  4. package/dist/commands/tasks-command.d.ts +15 -0
  5. package/dist/commands/tasks-command.js +117 -0
  6. package/dist/goal-coordinator.d.ts +22 -0
  7. package/dist/goal-coordinator.js +28 -0
  8. package/dist/goal-reducer.d.ts +89 -0
  9. package/dist/goal-reducer.js +181 -0
  10. package/dist/goal-store.d.ts +31 -0
  11. package/dist/goal-store.js +298 -0
  12. package/dist/goal-types.d.ts +4 -0
  13. package/dist/index.js +125 -1212
  14. package/dist/runtime/monitor-ondone-runtime.d.ts +13 -0
  15. package/dist/runtime/monitor-ondone-runtime.js +49 -0
  16. package/dist/runtime/notification-runtime.d.ts +34 -0
  17. package/dist/runtime/notification-runtime.js +152 -0
  18. package/dist/runtime/scope.d.ts +8 -0
  19. package/dist/runtime/scope.js +33 -0
  20. package/dist/runtime/session-runtime.d.ts +39 -0
  21. package/dist/runtime/session-runtime.js +110 -0
  22. package/dist/runtime/task-backlog-runtime.d.ts +36 -0
  23. package/dist/runtime/task-backlog-runtime.js +105 -0
  24. package/dist/runtime/task-rpc.d.ts +19 -0
  25. package/dist/runtime/task-rpc.js +118 -0
  26. package/dist/store.d.ts +5 -4
  27. package/dist/store.js +50 -44
  28. package/dist/task-store.d.ts +6 -4
  29. package/dist/task-store.js +64 -47
  30. package/dist/tools/loop-tools.d.ts +41 -0
  31. package/dist/tools/loop-tools.js +241 -0
  32. package/dist/tools/monitor-tools.d.ts +25 -0
  33. package/dist/tools/monitor-tools.js +110 -0
  34. package/dist/tools/native-task-tools.d.ts +15 -0
  35. package/dist/tools/native-task-tools.js +127 -0
  36. package/package.json +1 -1
  37. package/src/commands/loop-command.ts +184 -0
  38. package/src/commands/tasks-command.ts +135 -0
  39. package/src/goal-coordinator.ts +58 -0
  40. package/src/goal-reducer.ts +280 -0
  41. package/src/goal-store.ts +315 -0
  42. package/src/goal-types.ts +5 -0
  43. package/src/index.ts +129 -1299
  44. package/src/runtime/monitor-ondone-runtime.ts +75 -0
  45. package/src/runtime/notification-runtime.ts +212 -0
  46. package/src/runtime/scope.ts +37 -0
  47. package/src/runtime/session-runtime.ts +168 -0
  48. package/src/runtime/task-backlog-runtime.ts +163 -0
  49. package/src/runtime/task-rpc.ts +150 -0
  50. package/src/store.ts +51 -45
  51. package/src/task-store.ts +61 -46
  52. package/src/tools/loop-tools.ts +304 -0
  53. package/src/tools/monitor-tools.ts +145 -0
  54. package/src/tools/native-task-tools.ts +144 -0
@@ -0,0 +1,184 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ ExtensionUIContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { parseInterval } from "../loop-parse.js";
7
+ import type { LoopEntry, Trigger } from "../types.js";
8
+
9
+ interface LoopStoreLike {
10
+ list(): LoopEntry[];
11
+ get(id: string): LoopEntry | undefined;
12
+ create(trigger: Trigger, prompt: string, options?: Partial<LoopEntry>): LoopEntry;
13
+ pause(id: string): LoopEntry | undefined;
14
+ resume(id: string): LoopEntry | undefined;
15
+ delete(id: string): boolean;
16
+ }
17
+
18
+ interface TriggerSystemLike {
19
+ add(entry: LoopEntry): void;
20
+ remove(id: string): void;
21
+ }
22
+
23
+ export interface LoopCommandOptions {
24
+ pi: ExtensionAPI;
25
+ getStore: () => LoopStoreLike;
26
+ getTriggerSystem: () => TriggerSystemLike;
27
+ updateWidget: () => void;
28
+ }
29
+
30
+ export function registerLoopCommand(options: LoopCommandOptions): void {
31
+ const { pi, getStore, getTriggerSystem, updateWidget } = options;
32
+
33
+ async function scheduleLoop(ui: ExtensionUIContext, prompt?: string) {
34
+ const p = prompt || await ui.input("Prompt (what should the agent check?)");
35
+ if (!p) return;
36
+
37
+ const interval = await ui.input("Interval (e.g., 5m, 2h, 1d)");
38
+ if (!interval) return;
39
+
40
+ try {
41
+ const parsed = parseInterval(interval);
42
+ const trigger: Trigger = { type: "cron", schedule: parsed.cron };
43
+ const entry = getStore().create(trigger, p, { recurring: true });
44
+ getTriggerSystem().add(entry);
45
+ updateWidget();
46
+ ui.notify(`Loop #${entry.id} created: every ${parsed.description}`, "info");
47
+ } catch (err: unknown) {
48
+ ui.notify((err as Error).message, "error");
49
+ }
50
+ }
51
+
52
+ async function eventLoop(ui: ExtensionUIContext, prompt?: string) {
53
+ const p = prompt || await ui.input("Prompt");
54
+ if (!p) return;
55
+
56
+ const source = await ui.input("Pi event source (e.g., tool_execution_start, before_agent_start)");
57
+ if (!source) return;
58
+
59
+ const trigger: Trigger = { type: "event", source };
60
+ const entry = getStore().create(trigger, p, { recurring: true });
61
+ getTriggerSystem().add(entry);
62
+ updateWidget();
63
+ ui.notify(`Event loop #${entry.id} created: fires on "${source}"`, "info");
64
+ }
65
+
66
+ async function viewLoops(ui: ExtensionUIContext) {
67
+ const loops = getStore().list();
68
+ if (loops.length === 0) {
69
+ await ui.select("No active loops", ["< Back"]);
70
+ return;
71
+ }
72
+
73
+ const choices = loops.map((l) => {
74
+ const icon = l.status === "active" ? "*" : l.status === "paused" ? "-" : "x";
75
+ const triggerDesc = l.trigger.type === "cron"
76
+ ? `cron: ${l.trigger.schedule}`
77
+ : l.trigger.type === "event"
78
+ ? `event: ${l.trigger.source}`
79
+ : `hybrid: ${l.trigger.cron}`;
80
+ return `${icon} #${l.id} [${l.status}] ${l.prompt.slice(0, 50)} (${triggerDesc})`;
81
+ });
82
+ choices.push("< Back");
83
+
84
+ const selected = await ui.select("Active Loops", choices);
85
+ if (!selected || selected === "< Back") return;
86
+
87
+ const match = selected.match(/#(\d+)/);
88
+ if (match) {
89
+ const entry = getStore().get(match[1]);
90
+ if (entry) {
91
+ const actions = ["x Delete"];
92
+ if (entry.status === "active") actions.unshift("- Pause");
93
+ else if (entry.status === "paused") actions.unshift("* Resume");
94
+ actions.push("< Back");
95
+
96
+ const action = await ui.select(
97
+ `#${entry.id}: ${entry.prompt}\nTrigger: ${JSON.stringify(entry.trigger)}`,
98
+ actions,
99
+ );
100
+
101
+ if (action === "x Delete") {
102
+ getTriggerSystem().remove(entry.id);
103
+ getStore().delete(entry.id);
104
+ updateWidget();
105
+ ui.notify(`Loop #${entry.id} deleted`, "info");
106
+ } else if (action === "- Pause") {
107
+ getStore().pause(entry.id);
108
+ getTriggerSystem().remove(entry.id);
109
+ updateWidget();
110
+ ui.notify(`Loop #${entry.id} paused`, "info");
111
+ } else if (action === "* Resume") {
112
+ getStore().resume(entry.id);
113
+ getTriggerSystem().add(entry);
114
+ updateWidget();
115
+ ui.notify(`Loop #${entry.id} resumed`, "info");
116
+ }
117
+ }
118
+ }
119
+
120
+ return viewLoops(ui);
121
+ }
122
+
123
+ async function settings(ui: ExtensionUIContext) {
124
+ const loops = getStore().list();
125
+ const active = loops.filter((l) => l.status === "active").length;
126
+ ui.notify(`${active}/${loops.length} active loops (max 25)`, "info");
127
+ }
128
+
129
+ pi.registerCommand("loop", {
130
+ description: "Create a repeating scheduled task: /loop [interval] [prompt]. E.g., /loop 5m check the deploy, /loop 30s am I still here",
131
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
132
+ const trimmed = args.trim();
133
+ const ui = ctx.ui;
134
+
135
+ if (!trimmed) {
136
+ const choice = await ui.select("Loop", [
137
+ "Create scheduled loop",
138
+ "Create event-triggered loop",
139
+ "View active loops",
140
+ "Settings",
141
+ ]);
142
+
143
+ if (!choice) return;
144
+ if (choice.startsWith("Create scheduled")) return scheduleLoop(ui);
145
+ if (choice.startsWith("Create event")) return eventLoop(ui);
146
+ if (choice.startsWith("View active")) return viewLoops(ui);
147
+ return settings(ui);
148
+ }
149
+
150
+ const intervalMatch = trimmed.match(/^(\d+\s*[smhdS]\b)/i);
151
+ if (intervalMatch) {
152
+ const interval = intervalMatch[1];
153
+ const prompt = trimmed.slice(intervalMatch[0].length).trim();
154
+
155
+ if (!prompt) {
156
+ ui.notify("Provide a prompt after the interval, e.g., /loop 5m check the deploy", "warning");
157
+ return;
158
+ }
159
+
160
+ try {
161
+ const parsed = parseInterval(interval);
162
+ const trigger: Trigger = { type: "cron", schedule: parsed.cron };
163
+ const entry = getStore().create(trigger, prompt, { recurring: true });
164
+ getTriggerSystem().add(entry);
165
+ updateWidget();
166
+ ui.notify(`Loop #${entry.id} created: every ${parsed.description} — ${prompt.slice(0, 50)}`, "info");
167
+ } catch (err: unknown) {
168
+ ui.notify((err as Error).message, "error");
169
+ }
170
+ return;
171
+ }
172
+
173
+ const choice = await ui.select("Loop mode", [
174
+ `Scheduled: "${trimmed.slice(0, 50)}"`,
175
+ `Event-triggered: "${trimmed.slice(0, 50)}"`,
176
+ `Self-paced: "${trimmed.slice(0, 50)}"`,
177
+ ]);
178
+
179
+ if (!choice) return;
180
+ if (choice.startsWith("Event")) return eventLoop(ui, trimmed);
181
+ return scheduleLoop(ui, trimmed);
182
+ },
183
+ });
184
+ }
@@ -0,0 +1,135 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import { TaskStore } from "../task-store.js";
3
+ import type { TaskEntry } from "../task-types.js";
4
+
5
+ export interface TaskBacklogResult {
6
+ created: boolean;
7
+ entry?: { id: string };
8
+ }
9
+
10
+ export interface TasksCommandOptions {
11
+ pi: ExtensionAPI;
12
+ getNativeTaskStore: () => TaskStore | undefined;
13
+ evaluateTaskBacklog: (taskStore: TaskStore, pendingCount: number) => Promise<TaskBacklogResult>;
14
+ updateWidget: () => void;
15
+ }
16
+
17
+ export function registerTasksCommand(options: TasksCommandOptions): void {
18
+ const { pi, getNativeTaskStore, evaluateTaskBacklog, updateWidget } = options;
19
+
20
+ async function emitCreated(entry: TaskEntry) {
21
+ pi.events.emit("tasks:created", {
22
+ taskId: entry.id,
23
+ subject: entry.subject,
24
+ description: entry.description,
25
+ status: entry.status,
26
+ });
27
+ const taskStore = getNativeTaskStore();
28
+ if (!taskStore) return { created: false } satisfies TaskBacklogResult;
29
+ const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
30
+ updateWidget();
31
+ return backlog;
32
+ }
33
+
34
+ async function createNativeTaskInteractively(ui: ExtensionUIContext) {
35
+ const taskStore = getNativeTaskStore();
36
+ if (!taskStore) {
37
+ ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
38
+ return;
39
+ }
40
+
41
+ const subject = await ui.input("Task subject");
42
+ if (!subject) return;
43
+ const description = await ui.input("Task description") || subject;
44
+ const entry = taskStore.create(subject, description);
45
+ const backlog = await emitCreated(entry);
46
+ ui.notify(`Task #${entry.id} created`, "info");
47
+ if (backlog.created && backlog.entry) {
48
+ ui.notify(`Worker loop #${backlog.entry.id} auto-created`, "info");
49
+ }
50
+ }
51
+
52
+ async function viewNativeTasks(ui: ExtensionUIContext): Promise<void> {
53
+ const taskStore = getNativeTaskStore();
54
+ if (!taskStore) {
55
+ ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
56
+ return;
57
+ }
58
+
59
+ const tasks = taskStore.list();
60
+ const choices = tasks.map((task) => {
61
+ const icon = task.status === "in_progress" ? ">" : task.status === "completed" ? "ok" : "*";
62
+ return `${icon} #${task.id} [${task.status}] ${task.subject.slice(0, 60)}`;
63
+ });
64
+ choices.unshift("+ Create task");
65
+ choices.push("< Back");
66
+
67
+ const selected = await ui.select("Native Tasks", choices);
68
+ if (!selected || selected === "< Back") return;
69
+ if (selected === "+ Create task") {
70
+ await createNativeTaskInteractively(ui);
71
+ return viewNativeTasks(ui);
72
+ }
73
+
74
+ const match = selected.match(/#(\d+)/);
75
+ if (!match) return viewNativeTasks(ui);
76
+
77
+ const task = taskStore.get(match[1]);
78
+ if (!task) return viewNativeTasks(ui);
79
+
80
+ const actions = ["x Delete"];
81
+ if (task.status === "pending") {
82
+ actions.unshift("ok Complete");
83
+ actions.unshift("> Start");
84
+ } else if (task.status === "in_progress") {
85
+ actions.unshift("ok Complete");
86
+ actions.unshift("* Return to pending");
87
+ } else {
88
+ actions.unshift("* Reopen");
89
+ }
90
+ actions.push("< Back");
91
+
92
+ const action = await ui.select(`#${task.id}: ${task.subject}\n\n${task.description}`, actions);
93
+ if (!action || action === "< Back") return viewNativeTasks(ui);
94
+
95
+ if (action === "x Delete") {
96
+ taskStore.delete(task.id);
97
+ ui.notify(`Task #${task.id} deleted`, "info");
98
+ } else if (action === "> Start") {
99
+ taskStore.start(task.id);
100
+ ui.notify(`Task #${task.id} started`, "info");
101
+ } else if (action === "ok Complete") {
102
+ taskStore.complete(task.id);
103
+ ui.notify(`Task #${task.id} completed`, "info");
104
+ } else if (action === "* Return to pending" || action === "* Reopen") {
105
+ taskStore.reopen(task.id);
106
+ ui.notify(`Task #${task.id} reopened`, "info");
107
+ }
108
+
109
+ updateWidget();
110
+ await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
111
+ return viewNativeTasks(ui);
112
+ }
113
+
114
+ pi.registerCommand("tasks", {
115
+ description: "View or manage native pi-loop tasks when pi-tasks is not installed",
116
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
117
+ const trimmed = args.trim();
118
+ const taskStore = getNativeTaskStore();
119
+ if (!taskStore) {
120
+ ctx.ui.notify("Native tasks are unavailable while pi-tasks is active", "warning");
121
+ return;
122
+ }
123
+ if (trimmed) {
124
+ const entry = taskStore.create(trimmed.slice(0, 80), trimmed);
125
+ const backlog = await emitCreated(entry);
126
+ ctx.ui.notify(`Task #${entry.id} created`, "info");
127
+ if (backlog.created && backlog.entry) {
128
+ ctx.ui.notify(`Worker loop #${backlog.entry.id} auto-created`, "info");
129
+ }
130
+ return;
131
+ }
132
+ await viewNativeTasks(ctx.ui);
133
+ },
134
+ });
135
+ }
@@ -0,0 +1,58 @@
1
+ import type { ReducerEffect, ReducerEvent } from "./coordinator.js";
2
+ import type { GoalReducerState } from "./goal-types.js";
3
+ import { verifyGoal } from "./goal-verifier.js";
4
+ import type { LoopReducerState } from "./loop-reducer.js";
5
+ import type { MonitorReducerState } from "./monitor-reducer.js";
6
+ import type { TaskReducerState } from "./task-reducer.js";
7
+
8
+ export type GoalVerificationRequestedEvent = ReducerEvent<
9
+ "GOAL_VERIFICATION_REQUESTED",
10
+ { id: string }
11
+ >;
12
+
13
+ export interface GoalCoordinatorSnapshot {
14
+ goalState: GoalReducerState;
15
+ taskState: TaskReducerState;
16
+ loopState: LoopReducerState;
17
+ monitorState: MonitorReducerState;
18
+ }
19
+
20
+ export function reduceGoalVerificationRequest(
21
+ event: GoalVerificationRequestedEvent,
22
+ snapshot: GoalCoordinatorSnapshot,
23
+ ): ReducerEffect[] {
24
+ if (event.type !== "GOAL_VERIFICATION_REQUESTED") return [];
25
+
26
+ const goal = snapshot.goalState.goalsById[event.payload.id];
27
+ if (!goal) return [];
28
+
29
+ return verifyGoal({
30
+ goal,
31
+ taskState: snapshot.taskState,
32
+ loopState: snapshot.loopState,
33
+ monitorState: snapshot.monitorState,
34
+ at: event.at,
35
+ }).effects;
36
+ }
37
+
38
+ export interface GoalCoordinatorOptions {
39
+ getGoalState: () => GoalReducerState;
40
+ getTaskState: () => TaskReducerState;
41
+ getLoopState: () => LoopReducerState;
42
+ getMonitorState: () => MonitorReducerState;
43
+ }
44
+
45
+ export function createGoalCoordinatorReducer(options: GoalCoordinatorOptions) {
46
+ const { getGoalState, getTaskState, getLoopState, getMonitorState } = options;
47
+
48
+ return (event: ReducerEvent): ReducerEffect[] => {
49
+ if (event.type !== "GOAL_VERIFICATION_REQUESTED") return [];
50
+
51
+ return reduceGoalVerificationRequest(event as GoalVerificationRequestedEvent, {
52
+ goalState: getGoalState(),
53
+ taskState: getTaskState(),
54
+ loopState: getLoopState(),
55
+ monitorState: getMonitorState(),
56
+ });
57
+ };
58
+ }
@@ -0,0 +1,280 @@
1
+ import type {
2
+ GoalCriteria,
3
+ GoalEntry,
4
+ GoalProgressSnapshot,
5
+ GoalReducerState,
6
+ GoalScope,
7
+ GoalVerificationState,
8
+ } from "./goal-types.js";
9
+
10
+ export type GoalReducerEvent =
11
+ | {
12
+ type: "GOAL_CREATED";
13
+ at: number;
14
+ source: "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
15
+ entityType?: "goal";
16
+ entityId?: string;
17
+ payload: {
18
+ title: string;
19
+ description: string;
20
+ scope: GoalScope;
21
+ criteria: GoalCriteria;
22
+ metadata?: Record<string, unknown>;
23
+ };
24
+ }
25
+ | {
26
+ type: "GOAL_ACTIVATED" | "GOAL_VERIFICATION_STARTED" | "GOAL_UNBLOCKED";
27
+ at: number;
28
+ source: "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
29
+ entityType?: "goal";
30
+ entityId?: string;
31
+ payload: { id: string };
32
+ }
33
+ | {
34
+ type: "GOAL_PROGRESS_RECORDED";
35
+ at: number;
36
+ source: "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
37
+ entityType?: "goal";
38
+ entityId?: string;
39
+ payload: { id: string; progress: GoalProgressSnapshot };
40
+ }
41
+ | {
42
+ type: "GOAL_VERIFICATION_PASSED" | "GOAL_VERIFICATION_FAILED" | "GOAL_BLOCKED" | "GOAL_FAILED";
43
+ at: number;
44
+ source: "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
45
+ entityType?: "goal";
46
+ entityId?: string;
47
+ payload: { id: string; reason: string; progress?: GoalProgressSnapshot };
48
+ }
49
+ | {
50
+ type: "GOAL_SATISFIED" | "GOAL_ARCHIVED";
51
+ at: number;
52
+ source: "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
53
+ entityType?: "goal";
54
+ entityId?: string;
55
+ payload: { id: string; reason?: string };
56
+ }
57
+ | {
58
+ type: "GOAL_UPDATED";
59
+ at: number;
60
+ source: "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
61
+ entityType?: "goal";
62
+ entityId?: string;
63
+ payload: {
64
+ id: string;
65
+ title?: string;
66
+ description?: string;
67
+ scope?: GoalScope;
68
+ criteria?: GoalCriteria;
69
+ metadata?: Record<string, unknown>;
70
+ };
71
+ };
72
+
73
+ export type GoalReducerEffect =
74
+ | {
75
+ type: "PERSIST_GOAL";
76
+ entityType: "goal";
77
+ entityId: string;
78
+ payload: { goal: GoalEntry };
79
+ }
80
+ | {
81
+ type: "REQUEST_GOAL_VERIFICATION";
82
+ entityType: "goal";
83
+ entityId: string;
84
+ payload: { id: string };
85
+ };
86
+
87
+ export interface GoalReduceResult {
88
+ state: GoalReducerState;
89
+ effects: GoalReducerEffect[];
90
+ }
91
+
92
+ function cloneState(state: GoalReducerState): GoalReducerState {
93
+ return {
94
+ nextId: state.nextId,
95
+ goalsById: { ...state.goalsById },
96
+ };
97
+ }
98
+
99
+ function emptyProgress(): GoalProgressSnapshot {
100
+ return {
101
+ totalTasks: 0,
102
+ pendingTasks: 0,
103
+ inProgressTasks: 0,
104
+ completedTasks: 0,
105
+ activeLoops: 0,
106
+ pausedLoops: 0,
107
+ runningMonitors: 0,
108
+ completedMonitors: 0,
109
+ erroredMonitors: 0,
110
+ stoppedMonitors: 0,
111
+ };
112
+ }
113
+
114
+ function emptyVerification(): GoalVerificationState {
115
+ return {
116
+ attempts: 0,
117
+ passes: 0,
118
+ failures: 0,
119
+ };
120
+ }
121
+
122
+ function persist(goal: GoalEntry): GoalReducerEffect {
123
+ return {
124
+ type: "PERSIST_GOAL",
125
+ entityType: "goal",
126
+ entityId: goal.id,
127
+ payload: { goal },
128
+ };
129
+ }
130
+
131
+ function isTerminal(goal: GoalEntry): boolean {
132
+ return goal.status === "satisfied" || goal.status === "failed" || goal.status === "archived";
133
+ }
134
+
135
+ export function reduceGoalState(state: GoalReducerState, event: GoalReducerEvent): GoalReduceResult {
136
+ if (event.type === "GOAL_CREATED") {
137
+ const next = cloneState(state);
138
+ const id = String(next.nextId++);
139
+ const goal: GoalEntry = {
140
+ id,
141
+ title: event.payload.title,
142
+ description: event.payload.description,
143
+ status: "pending",
144
+ verificationStatus: "unknown",
145
+ createdAt: event.at,
146
+ updatedAt: event.at,
147
+ scope: event.payload.scope,
148
+ criteria: event.payload.criteria,
149
+ progress: emptyProgress(),
150
+ verification: emptyVerification(),
151
+ metadata: event.payload.metadata,
152
+ };
153
+ next.goalsById[id] = goal;
154
+ return {
155
+ state: next,
156
+ effects: [persist(goal)],
157
+ };
158
+ }
159
+
160
+ const id = event.payload.id;
161
+ const current = state.goalsById[id];
162
+ if (!current) return { state, effects: [] };
163
+ if (isTerminal(current) && event.type !== "GOAL_ARCHIVED") return { state, effects: [] };
164
+
165
+ const next = cloneState(state);
166
+ const goal: GoalEntry = {
167
+ ...current,
168
+ progress: { ...current.progress },
169
+ verification: { ...current.verification },
170
+ };
171
+
172
+ let extraEffects: GoalReducerEffect[] = [];
173
+
174
+ if (event.type === "GOAL_ACTIVATED") {
175
+ goal.status = "active";
176
+ goal.activatedAt ??= event.at;
177
+ goal.updatedAt = event.at;
178
+ extraEffects = [{
179
+ type: "REQUEST_GOAL_VERIFICATION",
180
+ entityType: "goal",
181
+ entityId: id,
182
+ payload: { id },
183
+ }];
184
+ }
185
+
186
+ if (event.type === "GOAL_PROGRESS_RECORDED") {
187
+ goal.progress = event.payload.progress;
188
+ goal.updatedAt = event.at;
189
+ }
190
+
191
+ if (event.type === "GOAL_VERIFICATION_STARTED") {
192
+ goal.verificationStatus = "checking";
193
+ goal.verification.attempts += 1;
194
+ goal.verification.lastCheckedAt = event.at;
195
+ goal.updatedAt = event.at;
196
+ }
197
+
198
+ if (event.type === "GOAL_VERIFICATION_PASSED") {
199
+ if (event.payload.progress) goal.progress = event.payload.progress;
200
+ goal.status = "satisfied";
201
+ goal.verificationStatus = "verified";
202
+ goal.verification.passes += 1;
203
+ goal.verification.lastPassedAt = event.at;
204
+ goal.verification.lastCheckedAt = event.at;
205
+ goal.verification.lastReason = event.payload.reason;
206
+ goal.resolvedAt = event.at;
207
+ goal.updatedAt = event.at;
208
+ }
209
+
210
+ if (event.type === "GOAL_VERIFICATION_FAILED") {
211
+ if (event.payload.progress) goal.progress = event.payload.progress;
212
+ goal.verificationStatus = "unverified";
213
+ goal.verification.failures += 1;
214
+ goal.verification.lastFailedAt = event.at;
215
+ goal.verification.lastCheckedAt = event.at;
216
+ goal.verification.lastReason = event.payload.reason;
217
+ if (goal.status === "pending") goal.status = "active";
218
+ goal.updatedAt = event.at;
219
+ }
220
+
221
+ if (event.type === "GOAL_BLOCKED") {
222
+ if (event.payload.progress) goal.progress = event.payload.progress;
223
+ goal.status = "blocked";
224
+ goal.verificationStatus = "inconclusive";
225
+ goal.verification.failures += 1;
226
+ goal.verification.lastFailedAt = event.at;
227
+ goal.verification.lastCheckedAt = event.at;
228
+ goal.verification.lastReason = event.payload.reason;
229
+ goal.updatedAt = event.at;
230
+ }
231
+
232
+ if (event.type === "GOAL_UNBLOCKED") {
233
+ goal.status = "active";
234
+ goal.updatedAt = event.at;
235
+ }
236
+
237
+ if (event.type === "GOAL_SATISFIED") {
238
+ goal.status = "satisfied";
239
+ goal.verificationStatus = "verified";
240
+ goal.verification.lastReason = event.payload.reason ?? goal.verification.lastReason;
241
+ goal.resolvedAt = event.at;
242
+ goal.updatedAt = event.at;
243
+ }
244
+
245
+ if (event.type === "GOAL_FAILED") {
246
+ if (event.payload.progress) goal.progress = event.payload.progress;
247
+ goal.status = "failed";
248
+ goal.verificationStatus = "unverified";
249
+ goal.verification.failures += 1;
250
+ goal.verification.lastFailedAt = event.at;
251
+ goal.verification.lastCheckedAt = event.at;
252
+ goal.verification.lastReason = event.payload.reason;
253
+ goal.resolvedAt = event.at;
254
+ goal.updatedAt = event.at;
255
+ }
256
+
257
+ if (event.type === "GOAL_ARCHIVED") {
258
+ goal.status = "archived";
259
+ goal.resolvedAt = event.at;
260
+ goal.updatedAt = event.at;
261
+ if (event.payload.reason !== undefined) {
262
+ goal.verification.lastReason = event.payload.reason;
263
+ }
264
+ }
265
+
266
+ if (event.type === "GOAL_UPDATED") {
267
+ if (event.payload.title !== undefined) goal.title = event.payload.title;
268
+ if (event.payload.description !== undefined) goal.description = event.payload.description;
269
+ if (event.payload.scope !== undefined) goal.scope = event.payload.scope;
270
+ if (event.payload.criteria !== undefined) goal.criteria = event.payload.criteria;
271
+ if (event.payload.metadata !== undefined) goal.metadata = event.payload.metadata;
272
+ goal.updatedAt = event.at;
273
+ }
274
+
275
+ next.goalsById[id] = goal;
276
+ return {
277
+ state: next,
278
+ effects: [persist(goal), ...extraEffects],
279
+ };
280
+ }