bingocode 1.1.183 → 1.1.184

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,463 @@
1
+ /**
2
+ * GoalStore — in-memory state manager for the 4-layer goal hierarchy.
3
+ *
4
+ * Replaces the flat goal fields previously in bootstrap/state.ts with a
5
+ * structured, queryable state object. Provides both mutation and query
6
+ * APIs, plus signal-based change notification for React hooks.
7
+ *
8
+ * Architecture:
9
+ * GoalStore (this module) — in-memory singleton, synchronous mutations
10
+ * GoalPersistence (goalPersistence.ts) — disk I/O for save/load
11
+ * GoalDag (goalDag.ts) — DAG engine for topological operations
12
+ */
13
+
14
+ import type { GoalState, SubGoal, SubGoalStatus, GoalId } from '../types/goal.js'
15
+ import { asGoalId } from '../types/goal.js'
16
+ import { randomUUID } from '../utils/crypto.js'
17
+ import { createSignal } from '../utils/signal.js'
18
+ import { saveGoalState, loadGoalState, getGoalFilePath, ensureGoalsDir, archiveGoal, deleteGoal, readVersion, saveIfUnchanged } from '../utils/goalPersistence.js'
19
+ import { GoalDagEngine } from '../utils/goalDag.js'
20
+ import { assessDrift } from '../utils/goalDrift.js'
21
+
22
+ // ============================================================================
23
+ // Module state
24
+ // ============================================================================
25
+
26
+ /** In-memory goal state. */
27
+ let store: GoalState = createEmptyGoalState()
28
+ /** Signal emitter for change notification (React hook subscription). */
29
+ const goalChanged = createSignal<[GoalState]>()
30
+ /** DAG engine — single source of truth for sub-goal graph operations. */
31
+ const dagEngine = new GoalDagEngine()
32
+ /** Current on-disk version. Used for optimistic concurrency control. */
33
+ let currentVersion = 0
34
+
35
+ /** Create a fresh empty goal state with all defaults. */
36
+ export function createEmptyGoalState(): GoalState {
37
+ return {
38
+ activeGoalId: null,
39
+ userGoal: null,
40
+ operationalGoal: null,
41
+ dag: { nodes: new Map(), edges: [], readyQueue: [] },
42
+ immediateGoal: null,
43
+ progress: 0,
44
+ artifacts: [],
45
+ metrics: {
46
+ totalSubGoals: 0,
47
+ completedSubGoals: 0,
48
+ iterationCount: 0,
49
+ maxIterations: 20,
50
+ evalHistory: { lastGap: null, consecutiveSameGapCount: 0 },
51
+ },
52
+ }
53
+ }
54
+
55
+ // ============================================================================
56
+ // Signal — for React hook subscription
57
+ // ============================================================================
58
+
59
+ /** Subscribe to goal state changes. Returns unsubscribe function.
60
+ * Used by useGoalStore hook in React components. */
61
+ export const onGoalStateChanged = goalChanged.subscribe
62
+
63
+ // ============================================================================
64
+ // Accessors
65
+ // ============================================================================
66
+
67
+ export function getState(): GoalState {
68
+ return store
69
+ }
70
+
71
+ /** Alias for consumer code that imports getGoalState instead of getState. */
72
+ export const getGoalState = getState
73
+
74
+ export function getActiveGoalId(): GoalId | null {
75
+ return store.activeGoalId
76
+ }
77
+
78
+ export function hasActiveGoal(): boolean {
79
+ return store.activeGoalId !== null
80
+ }
81
+
82
+ /** Alias for consumer code that imports clearState instead of clear. */
83
+ export const clearState = clear
84
+
85
+ // ============================================================================
86
+ // Mutations
87
+ // ============================================================================
88
+
89
+ /** Set a new user goal. Creates an ID, marks the timestamp, and updates
90
+ * the active goal reference. Returns the new goal ID for downstream use. */
91
+ export function setUserGoal(text: string): GoalId {
92
+ const id = asGoalId(randomUUID())
93
+ store.activeGoalId = id
94
+ store.userGoal = {
95
+ id,
96
+ text,
97
+ createdAt: Date.now(),
98
+ updatedAt: Date.now(),
99
+ }
100
+ store.operationalGoal = null // reset — will be refined later
101
+ store.dag = { nodes: new Map(), edges: [], readyQueue: [] }
102
+ store.immediateGoal = null
103
+ store.progress = 0
104
+ store.artifacts = []
105
+ store.metrics = { ...createEmptyGoalState().metrics }
106
+
107
+ // Reset the DAG engine too — new goal starts with a clean graph
108
+ dagEngine.reset()
109
+
110
+ emit()
111
+ return id
112
+ }
113
+
114
+ /** Set the operational goal. The caller (typically the planning agent)
115
+ * refines the user goal into an executable contract. */
116
+ export function setOperationalGoal(text: string): void {
117
+ if (!store.userGoal) {
118
+ throw new Error('Cannot set operational goal without a user goal')
119
+ }
120
+ store.operationalGoal = {
121
+ id: store.userGoal.id,
122
+ text,
123
+ derivedFrom: store.userGoal.id,
124
+ constraints: [],
125
+ successCriteria: [],
126
+ createdAt: Date.now(),
127
+ }
128
+ emit()
129
+ }
130
+
131
+ /** Declare the current immediate action target. Updates the runtime
132
+ * pointer to which sub-goal is being worked on right now. */
133
+ export function setImmediateGoal(subGoalId: string, action: string): void {
134
+ store.immediateGoal = {
135
+ subGoalId,
136
+ action,
137
+ declaredAt: Date.now(),
138
+ }
139
+ // Don't emit — too frequent, every turn. Callers can emit if needed.
140
+ }
141
+
142
+ /** Clear the immediate goal pointer. Called when the agent finishes
143
+ * a step and moves to the next. */
144
+ export function clearImmediateGoal(): void {
145
+ store.immediateGoal = null
146
+ }
147
+
148
+ // ============================================================================
149
+ // DAG mutations — delegate to GoalDagEngine
150
+ // ============================================================================
151
+
152
+ /** Add a sub-goal node to the DAG. Delegates to the engine for validation
153
+ * and topological ordering. */
154
+ export function addSubGoal(node: SubGoal): void {
155
+ dagEngine.addNode(node)
156
+ syncMetricsFromEngine()
157
+ emit()
158
+ }
159
+
160
+ /** Remove a sub-goal node from the DAG. */
161
+ export function removeSubGoal(nodeId: string): boolean {
162
+ const removed = dagEngine.removeNode(nodeId)
163
+ if (!removed) return false
164
+ syncMetricsFromEngine()
165
+ emit()
166
+ return true
167
+ }
168
+
169
+ /** Update a sub-goal's status. Triggers progress recalculation
170
+ * and syncs metrics from the engine to keep store/engine in sync. */
171
+ export function updateSubGoalStatus(nodeId: string, status: SubGoalStatus): void {
172
+ dagEngine.updateStatus(nodeId, status)
173
+ syncMetricsFromEngine()
174
+ recalculateProgress()
175
+ emit()
176
+ }
177
+
178
+ /** Reorder dependencies for a sub-goal node. */
179
+ export function reorderDependency(nodeId: string, newDeps: readonly string[]): void {
180
+ dagEngine.reorderDeps(nodeId, [...newDeps])
181
+ syncMetricsFromEngine()
182
+ emit()
183
+ }
184
+
185
+ /** Merge multiple source sub-goals into a single target. */
186
+ export function mergeSubGoals(sourceIds: string[], targetText: string): SubGoal {
187
+ const merged = dagEngine.mergeNodes(sourceIds, targetText)
188
+ syncMetricsFromEngine()
189
+ emit()
190
+ return merged
191
+ }
192
+
193
+ // ============================================================================
194
+ // Progress & metrics
195
+ // ============================================================================
196
+
197
+ /** Recalculate overall progress percentage from completed/total ratio. */
198
+ export function recalculateProgress(): number {
199
+ if (store.metrics.totalSubGoals === 0) {
200
+ store.progress = 0
201
+ } else {
202
+ const completed = countByStatus('completed')
203
+ store.metrics.completedSubGoals = completed
204
+ store.progress = Math.round((completed / store.metrics.totalSubGoals) * 100)
205
+ }
206
+ return store.progress
207
+ }
208
+
209
+ /** Increment the iteration counter. Called after each evaluation cycle. */
210
+ export function incrementIteration(): void {
211
+ store.metrics.iterationCount++
212
+ }
213
+
214
+ /** Record evaluation gap for staleness tracking. Resets on different gap,
215
+ * accumulates on same gap (triggers circuit breaker at 3). */
216
+ export function recordEvalGap(gap: string | null): void {
217
+ const history = store.metrics.evalHistory
218
+ if (gap === history.lastGap && gap !== null) {
219
+ history.consecutiveSameGapCount++
220
+ } else {
221
+ history.lastGap = gap
222
+ history.consecutiveSameGapCount = gap !== null ? 1 : 0
223
+ }
224
+ }
225
+
226
+ // ============================================================================
227
+ // Queries — delegate to engine
228
+ // ============================================================================
229
+
230
+ /** Get all sub-goals ready for execution. */
231
+ export function getReadySubGoals(): SubGoal[] {
232
+ const readyIds = dagEngine.getReadyNodes()
233
+ return readyIds.map(id => dagEngine.getAllNodes().find(n => n.id === id)).filter(Boolean) as SubGoal[]
234
+ }
235
+
236
+ /** Get all currently active sub-goals. */
237
+ export function getActiveSubGoals(): SubGoal[] {
238
+ return dagEngine.getAllNodes().filter(n => n.status === 'active')
239
+ }
240
+
241
+ /** Get all blocked sub-goals. */
242
+ export function getBlockedSubGoals(): SubGoal[] {
243
+ return dagEngine.getAllNodes().filter(n => n.status === 'blocked')
244
+ }
245
+
246
+ /** Get all completed sub-goals. */
247
+ export function getCompletedSubGoals(): SubGoal[] {
248
+ return dagEngine.getAllNodes().filter(n => n.status === 'completed')
249
+ }
250
+
251
+ /** Get all nodes in the DAG. */
252
+ export function getAllNodes(): SubGoal[] {
253
+ return dagEngine.getAllNodes()
254
+ }
255
+
256
+ /** Get the DAG engine instance for direct access. */
257
+ export function getDagEngine(): GoalDagEngine {
258
+ return dagEngine
259
+ }
260
+
261
+
262
+ // ============================================================================
263
+ // Artifacts
264
+ // ============================================================================
265
+
266
+ /** Record a file path as produced during goal execution. */
267
+ export function addArtifact(path: string): void {
268
+ if (!store.artifacts.includes(path)) {
269
+ store.artifacts.push(path)
270
+ }
271
+ }
272
+
273
+ // ============================================================================
274
+ // Persistence
275
+ // ============================================================================
276
+
277
+ /**
278
+ * Serialize current state to a JSON-safe representation.
279
+ * Uses the engine's Record format (not Map) so it survives JSON.stringify.
280
+ * The in-memory Map is rebuilt on load via rebuildFromEngineNodes().
281
+ */
282
+ export function toJSON(): GoalState {
283
+ const engineState = dagEngine.toJSON()
284
+ // Convert engine's Record<string, DagNode> to our store's Map format
285
+ // for the returned GoalState. This is a snapshot, not the live store.
286
+ const nodeMap = new Map<string, SubGoal>()
287
+ for (const [id, node] of Object.entries(engineState.nodes)) {
288
+ nodeMap.set(id, node as SubGoal)
289
+ }
290
+ return {
291
+ ...store,
292
+ dag: {
293
+ nodes: nodeMap,
294
+ edges: [...engineState.edges],
295
+ readyQueue: [],
296
+ },
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Load state from disk, overwriting current in-memory state.
302
+ * Rebuilds both the store and the DAG engine from the persisted data.
303
+ */
304
+ /**
305
+ * Load state from disk, overwriting current in-memory state.
306
+ * Rebuilds both the store and the DAG engine from the persisted data,
307
+ * and syncs the version tracker for optimistic concurrency control.
308
+ */
309
+ export function load(goalId: GoalId): boolean {
310
+ const loaded = loadGoalState(goalId)
311
+ if (!loaded) return false
312
+
313
+ // Reset both store and engine
314
+ store = {
315
+ ...loaded,
316
+ dag: { nodes: new Map(), edges: [], readyQueue: [] },
317
+ artifacts: loaded.artifacts ?? [],
318
+ }
319
+ dagEngine.reset()
320
+
321
+ // Rebuild DAG engine from persisted nodes (stored as JSON object)
322
+ // The persistence layer stores dag.nodes as a plain Record, not Map.
323
+ // We reconstruct the engine first, then sync back to the store.
324
+ const persistedNodes = (loaded as any).dag?.nodes
325
+ if (persistedNodes && typeof persistedNodes === 'object' && !(persistedNodes instanceof Map)) {
326
+ for (const [, node] of Object.entries(persistedNodes as Record<string, SubGoal>)) {
327
+ if (node && node.id) {
328
+ dagEngine.addNode(node)
329
+ }
330
+ }
331
+ }
332
+
333
+ // Sync version tracker from disk
334
+ currentVersion = readVersion(goalId)
335
+
336
+ syncMetricsFromEngine()
337
+ emit()
338
+ return true
339
+ }
340
+
341
+ /**
342
+ * Save current state to disk with version tracking.
343
+ * Returns the new on-disk version number after a successful write.
344
+ *
345
+ * The version number is used for optimistic concurrency control:
346
+ * if another agent modifies the file between our read and write,
347
+ * the next save will detect the conflict via version mismatch.
348
+ */
349
+ export function save(): number {
350
+ if (!store.activeGoalId) return 0
351
+ const engineState = dagEngine.toJSON()
352
+ // Convert engine nodes to plain object for JSON compatibility
353
+ const nodesObj: Record<string, SubGoal> = {}
354
+ for (const [id, node] of Object.entries(engineState.nodes)) {
355
+ nodesObj[id] = node as SubGoal
356
+ }
357
+ const newVersion = saveGoalState({
358
+ ...store,
359
+ dag: {
360
+ nodes: nodesObj as unknown as Map<string, SubGoal>,
361
+ edges: [...engineState.edges],
362
+ readyQueue: [],
363
+ },
364
+ })
365
+ currentVersion = newVersion
366
+ return newVersion
367
+ }
368
+
369
+ /** Get the current on-disk version number. Used for conflict detection
370
+ * in multi-agent scenarios. */
371
+ export function getVersion(): number {
372
+ return currentVersion
373
+ }
374
+
375
+ /** Save only if the on-disk version hasn't changed since we last read it.
376
+ * Returns the new version on success, or -1 on conflict (another writer
377
+ * modified the file). The caller should retry or merge.
378
+ *
379
+ * This is an optimistic concurrency control — prevents lost updates
380
+ * when multiple agents modify the same goal file concurrently. */
381
+ export function saveIfNoConflict(): number {
382
+ if (!store.activeGoalId) return -1
383
+ const engineState = dagEngine.toJSON()
384
+ const nodesObj: Record<string, SubGoal> = {}
385
+ for (const [id, node] of Object.entries(engineState.nodes)) {
386
+ nodesObj[id] = node as SubGoal
387
+ }
388
+ const result = saveIfUnchanged(currentVersion, {
389
+ ...store,
390
+ dag: {
391
+ nodes: nodesObj as unknown as Map<string, SubGoal>,
392
+ edges: [...engineState.edges],
393
+ readyQueue: [],
394
+ },
395
+ })
396
+ if (result > 0) currentVersion = result
397
+ return result
398
+ }
399
+
400
+
401
+ // ============================================================================
402
+ // Lifecycle
403
+ // ============================================================================
404
+
405
+ /** Reset to empty state. */
406
+ export function clear(): void {
407
+ store = createEmptyGoalState()
408
+ dagEngine.reset()
409
+ emit()
410
+ }
411
+
412
+ /** Reset and delete persisted file. */
413
+ export function clearAndDelete(): void {
414
+ const id = store.activeGoalId
415
+ store = createEmptyGoalState()
416
+ dagEngine.reset()
417
+ if (id) {
418
+ try { deleteGoal(id) } catch { /* file may not exist */ }
419
+ }
420
+ emit()
421
+ }
422
+
423
+ /** Archive current goal and reset. */
424
+ export function archive(): void {
425
+ const id = store.activeGoalId
426
+ if (id) {
427
+ try { archiveGoal(id) } catch { /* file may not exist */ }
428
+ }
429
+ clear()
430
+ }
431
+
432
+ // ============================================================================
433
+ // Helpers
434
+ // ============================================================================
435
+
436
+ /** Notify subscribers of state change. */
437
+ function emit(): void {
438
+ goalChanged.emit(store)
439
+ }
440
+
441
+ /** Sync metrics from DAG engine after mutations.
442
+ * Rebuilds the store's dag.nodes Map from the engine's node collection.
443
+ * The engine owns the canonical node list; we mirror it here for the
444
+ * store's query layer. */
445
+ function syncMetricsFromEngine(): void {
446
+ const engineNodes = dagEngine.getAllNodes()
447
+ const nodeMap = new Map<string, SubGoal>()
448
+ for (const node of engineNodes) {
449
+ nodeMap.set(node.id, node)
450
+ }
451
+ store.dag.nodes = nodeMap
452
+ store.dag.edges = [...dagEngine.toJSON().edges]
453
+ store.metrics.totalSubGoals = engineNodes.length
454
+ }
455
+
456
+ /** Count sub-goals by status. */
457
+ function countByStatus(status: SubGoalStatus): number {
458
+ let count = 0
459
+ for (const node of dagEngine.getAllNodes()) {
460
+ if (node.status === status) count++
461
+ }
462
+ return count
463
+ }