@deepseek-ai/dsh-goal 0.0.1-rc.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.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Pure types of the goal domain: the ONE home of the `goal` projection-key
3
+ * declaration plus the durable payload vocabulary it carries, free of this
4
+ * package's host-side imports (cordis events, dsh-agent, dsh-llm, the
5
+ * service). Two namespace projections serve it — `./types` for host
6
+ * consumers, `./client` (the browser half-entry's re-export) for client
7
+ * aggregates — with zero content duplication. Host-coupled domain
8
+ * vocabulary (message sources, events, fold shapes) lives in ./domain.ts.
9
+ *
10
+ * @module @deepseek-ai/dsh-goal/types
11
+ */
12
+ import type { Branded } from '@deepseek-ai/dsh-brand';
13
+ /** Identifies one goal across its durable revisions. */
14
+ export type GoalId = Branded<'GoalId'>;
15
+ /** Compare-and-set identity for one exact goal revision. */
16
+ export interface GoalRef {
17
+ /** Stable goal identity. */
18
+ readonly id: GoalId;
19
+ /** Positive revision; every durable mutation increments it. */
20
+ readonly revision: number;
21
+ }
22
+ /** Input whose omitted round cap is resolved by the service configuration. */
23
+ export interface CreateGoalRequest {
24
+ readonly objective: string;
25
+ readonly maxGoalRounds?: number;
26
+ }
27
+ /** Wire-safe acknowledgement of one created goal. */
28
+ export interface CreateGoalResult {
29
+ readonly ref: GoalRef;
30
+ }
31
+ /** Fields changed by an edit; at least one must be present. */
32
+ export interface EditGoalRequest {
33
+ readonly objective?: string;
34
+ readonly maxGoalRounds?: number;
35
+ }
36
+ /** Durable continuation phase. Activation is process-local and separate. */
37
+ export type GoalPhase = 'active' | 'paused' | 'blocked' | 'complete';
38
+ /** Machine-routable and human-readable explanation for a blocked goal. */
39
+ export interface GoalBlockReason {
40
+ /** Stable lower-kebab-case classification chosen by the blocking policy. */
41
+ readonly code: string;
42
+ /** Non-empty explanation shown to humans and models. */
43
+ readonly message: string;
44
+ }
45
+ /** Full durable state written by every non-clear goal mutation. */
46
+ export interface GoalSnapshot extends GoalRef {
47
+ /** Human-requested completion objective. */
48
+ readonly objective: string;
49
+ /** Durable lifecycle phase. */
50
+ readonly phase: GoalPhase;
51
+ /** Present exactly while `phase` is `blocked`. */
52
+ readonly blockedReason?: GoalBlockReason;
53
+ /** Total admitted goal-round cap. */
54
+ readonly maxGoalRounds: number;
55
+ }
56
+ /** Whether this live process may automatically continue an active goal. */
57
+ export type GoalActivation = 'armed' | 'disarmed';
58
+ /** Current goal projection, including values derived from the session log. */
59
+ export interface GoalView extends GoalSnapshot {
60
+ /** Highest admitted round number for this goal. */
61
+ readonly roundsStarted: number;
62
+ /** Epoch milliseconds of the create mutation. */
63
+ readonly createdAt: number;
64
+ /** Epoch milliseconds of the latest mutation. */
65
+ readonly updatedAt: number;
66
+ /** Process-local continuation eligibility; never persisted. */
67
+ readonly activation: GoalActivation;
68
+ }
69
+ /**
70
+ * The `goal` projection value: the current durable goal with its replay
71
+ * counters, exactly as the latest `goal/change` event carried them.
72
+ * Activation is process-local (never persisted) and deliberately absent —
73
+ * the projection reflects durable phase only.
74
+ */
75
+ export interface GoalProjection {
76
+ /** Current durable goal snapshot (the CAS ref for mutations rides on it). */
77
+ readonly goal: GoalSnapshot;
78
+ /** Highest admitted round number for this goal. */
79
+ readonly roundsStarted: number;
80
+ /** Epoch milliseconds of the create mutation. */
81
+ readonly createdAt: number;
82
+ /** Epoch milliseconds of the latest mutation. */
83
+ readonly updatedAt: number;
84
+ }
85
+ declare module '@deepseek-ai/dsh-session-projection/types' {
86
+ interface SessionProjectionMap {
87
+ /**
88
+ * The session's current goal (the latest `goal/change` whole value), or
89
+ * `null` before the first create and after a clear tombstone.
90
+ * Whole-value rule: every goal change carries the complete post-change
91
+ * state, so the fold is last-wins.
92
+ */
93
+ goal: GoalProjection | null;
94
+ }
95
+ }
96
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Pure types of the goal domain: the ONE home of the `goal` projection-key
3
+ * declaration plus the durable payload vocabulary it carries, free of this
4
+ * package's host-side imports (cordis events, dsh-agent, dsh-llm, the
5
+ * service). Two namespace projections serve it — `./types` for host
6
+ * consumers, `./client` (the browser half-entry's re-export) for client
7
+ * aggregates — with zero content duplication. Host-coupled domain
8
+ * vocabulary (message sources, events, fold shapes) lives in ./domain.ts.
9
+ *
10
+ * @module @deepseek-ai/dsh-goal/types
11
+ */
12
+ export {};
13
+ //# sourceMappingURL=types.js.map
package/package.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "name": "@deepseek-ai/dsh-goal",
3
+ "description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness",
4
+ "version": "0.0.1-rc.1",
5
+ "publishConfig": {
6
+ "access": "restricted"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "packages/goal/goal"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./invariant": {
22
+ "types": "./lib/types/invariant.d.ts",
23
+ "default": "./lib/invariant.js"
24
+ },
25
+ "./types": {
26
+ "types": "./lib/types/types.d.ts",
27
+ "default": "./lib/types/types.js"
28
+ },
29
+ "./client": {
30
+ "types": "./lib/types/client.d.ts",
31
+ "default": "./lib/types/client.js"
32
+ },
33
+ "./typert": {
34
+ "types": "./lib/typert.host.d.ts",
35
+ "default": "./lib/typert.host.js"
36
+ },
37
+ "./remote": {
38
+ "types": "./lib/typert.remote-client.d.ts",
39
+ "default": "./lib/typert.remote-client.js"
40
+ },
41
+ "./src/*": "./src/*",
42
+ "./package.json": "./package.json"
43
+ },
44
+ "files": [
45
+ "lib/index.js",
46
+ "lib/invariant.js",
47
+ "lib/types/**/*.js",
48
+ "lib/types/**/*.d.ts",
49
+ "lib/typert.host.js",
50
+ "lib/typert.host.d.ts",
51
+ "lib/typert.remote-client.js",
52
+ "lib/typert.remote-client.d.ts",
53
+ "lib/typert.remote-client.d.ts.map",
54
+ "src"
55
+ ],
56
+ "license": "BSD-3-Clause",
57
+ "peerDependencies": {
58
+ "@deepseek-ai/dsh-session-projection": "^0.0.1-rc.1",
59
+ "@deepseek-ai/dsh-brand": "^0.0.1-rc.1",
60
+ "@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
61
+ "@deepseek-ai/dsh-scope": "^0.0.1-rc.1",
62
+ "@deepseek-ai/dsh-session": "^0.0.1-rc.1",
63
+ "@deepseek-ai/dsh-type-meta": "^0.0.1-rc.1",
64
+ "@deepseek-ai/cordis": "^4.0.1-rc.1",
65
+ "@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
66
+ "@deepseek-ai/dsh-invariants": "^0.0.1-rc.1"
67
+ },
68
+ "dependencies": {
69
+ "zod": "^4.4.3",
70
+ "@deepseek-ai/schemastery": "^3.18.1-rc.1"
71
+ },
72
+ "devDependencies": {
73
+ "@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
74
+ "@deepseek-ai/dsh-session-projection": "^0.0.1-rc.1",
75
+ "@deepseek-ai/dsh-brand": "^0.0.1-rc.1",
76
+ "@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
77
+ "@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
78
+ "@deepseek-ai/dsh-session": "^0.0.1-rc.1",
79
+ "@deepseek-ai/dsh-type-meta": "^0.0.1-rc.1",
80
+ "@deepseek-ai/cordis": "^4.0.1-rc.1",
81
+ "@deepseek-ai/dsh-loader-smoke": "^0.0.1-rc.1",
82
+ "@deepseek-ai/dsh-scope": "^0.0.1-rc.1"
83
+ }
84
+ }
package/src/client.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Client-namespace projection of the goal domain: a pure re-export of the
3
+ * package's types outlet. Client code imports ONLY the client namespace
4
+ * (repo discipline), so `./client` projects the same single-source content
5
+ * `./types` serves to host consumers — zero duplication.
6
+ *
7
+ * @module @deepseek-ai/dsh-goal/client
8
+ */
9
+
10
+ export type * from './types.ts'
package/src/domain.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Host-side vocabulary of the goal domain: live views, durable change
3
+ * payloads, message attribution, replay folds, and the scoped `goal/changed`
4
+ * event. Kept separate from ./types.ts (the pure client-safe outlet) because
5
+ * these declarations pull dsh-agent, dsh-llm, and cordis into the program —
6
+ * the one-program-per-side layout forbids that on client aggregates.
7
+ * @module @deepseek-ai/dsh-goal
8
+ */
9
+
10
+ import type { Agent } from '@deepseek-ai/dsh-agent'
11
+ import type { GoalId, GoalRef, GoalSnapshot, GoalView } from './types.ts'
12
+
13
+ /** Goal state-changing verbs recorded in the durable source change. */
14
+ export type GoalOperation =
15
+ | 'create'
16
+ | 'edit'
17
+ | 'pause'
18
+ | 'resume'
19
+ | 'complete'
20
+ | 'block'
21
+ | 'clear'
22
+
23
+ /** Full-snapshot goal mutation committed by a durable `goal/change` event. */
24
+ export interface GoalSnapshotChangeMeta {
25
+ readonly kind: 'goal/change'
26
+ readonly version: 1
27
+ readonly operation: Exclude<GoalOperation, 'clear'>
28
+ readonly goal: GoalSnapshot
29
+ readonly roundsStarted: number
30
+ readonly createdAt: number
31
+ readonly updatedAt: number
32
+ }
33
+
34
+ /** Tombstone retained when the current goal is cleared. */
35
+ export interface GoalClearChangeMeta {
36
+ readonly kind: 'goal/change'
37
+ readonly version: 1
38
+ readonly operation: 'clear'
39
+ readonly cleared: GoalRef
40
+ readonly clearedAt: number
41
+ }
42
+
43
+ /** Durable change union carried by the goal domain's own session event. */
44
+ export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
45
+
46
+ /** Message attribution for admitted continuation rounds. */
47
+ export interface GoalMessageSource {
48
+ readonly kind: 'goal'
49
+ readonly goalId: GoalId
50
+ readonly revision: number
51
+ /** Positive admitted continuation round. */
52
+ readonly round: number
53
+ }
54
+
55
+ declare module '@deepseek-ai/dsh-llm' {
56
+ interface MessageSourceMap {
57
+ goal: GoalMessageSource
58
+ }
59
+ }
60
+
61
+ declare module '@deepseek-ai/dsh-session/types' {
62
+ interface SessionEventMap {
63
+ /**
64
+ * Complete post-mutation goal state or clear tombstone.
65
+ */
66
+ 'goal/change': GoalChangeMeta
67
+ }
68
+ }
69
+
70
+ /** Pure replay fold of durable goal facts. */
71
+ export interface FoldedGoal {
72
+ /** Current goal, absent after a clear or before the first create. */
73
+ readonly goal?: GoalSnapshot
74
+ /** Highest admitted round for the current goal. */
75
+ readonly roundsStarted: number
76
+ /** Current goal creation time, absent without a current goal. */
77
+ readonly createdAt?: number
78
+ /** Current goal mutation time, absent without a current goal. */
79
+ readonly updatedAt?: number
80
+ /** Latest mutation ref, including a clear tombstone. */
81
+ readonly lastRef?: GoalRef
82
+ }
83
+
84
+ /** Live notification after one durable goal mutation commits. */
85
+ export interface GoalChanged {
86
+ readonly operation: GoalOperation
87
+ readonly ref: GoalRef
88
+ /** Absent for a clear tombstone. */
89
+ readonly goal?: GoalView
90
+ }
91
+
92
+ /** Stable error codes for rejected goal reads and mutations. */
93
+ export type GoalErrorCode =
94
+ | 'GOAL_AGENT_NOT_LIVE'
95
+ | 'GOAL_NOT_FOUND'
96
+ | 'GOAL_ALREADY_EXISTS'
97
+ | 'GOAL_STALE_REVISION'
98
+ | 'GOAL_INVALID_OBJECTIVE'
99
+ | 'GOAL_INVALID_MAX_ROUNDS'
100
+ | 'GOAL_INVALID_BLOCK_REASON'
101
+ | 'GOAL_INVALID_EDIT'
102
+ | 'GOAL_INVALID_TRANSITION'
103
+
104
+ declare module '@deepseek-ai/cordis' {
105
+ interface Events {
106
+ /**
107
+ * Goal mutation accepted by one live agent. The matching `goal/change`
108
+ * session event has already committed. Listener failures are contained.
109
+ * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
110
+ * @param payload.agent - agent whose session owns the goal.
111
+ * @param payload.change - fresh current projection or clear tombstone.
112
+ * @mode emit
113
+ */
114
+ 'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, payload: { agent: Agent; change: GoalChanged }): void
115
+ }
116
+ }
package/src/fold.ts ADDED
@@ -0,0 +1,349 @@
1
+ /** Pure replay fold and strict decoder for durable goal changes. */
2
+
3
+ import type { MessageSource } from '@deepseek-ai/dsh-llm'
4
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
5
+ import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts'
6
+ import type { GoalBlockReason, GoalPhase, GoalRef, GoalSnapshot } from './types.ts'
7
+ import type {
8
+ FoldedGoal,
9
+ GoalChangeMeta,
10
+ GoalClearChangeMeta,
11
+ GoalMessageSource,
12
+ GoalOperation,
13
+ GoalSnapshotChangeMeta,
14
+ } from './domain.ts'
15
+
16
+ const SNAPSHOT_OPERATIONS: ReadonlySet<Exclude<GoalOperation, 'clear'>> = new Set([
17
+ 'create',
18
+ 'edit',
19
+ 'pause',
20
+ 'resume',
21
+ 'complete',
22
+ 'block',
23
+ ])
24
+ const PHASES: ReadonlySet<GoalPhase> = new Set(['active', 'paused', 'blocked', 'complete'])
25
+
26
+ /** Mutable accumulator kept private to the pure fold. */
27
+ export interface GoalFoldState {
28
+ goal: GoalSnapshot | undefined
29
+ roundsStarted: number
30
+ createdAt: number | undefined
31
+ updatedAt: number | undefined
32
+ lastRef: GoalRef | undefined
33
+ seenGoalIds: Set<GoalSnapshot['id']>
34
+ }
35
+
36
+ /**
37
+ * Build an empty replay accumulator.
38
+ * @returns mutable state with no current goal or prior ref.
39
+ */
40
+ export function emptyGoalFoldState(): GoalFoldState {
41
+ return {
42
+ goal: undefined,
43
+ roundsStarted: 0,
44
+ createdAt: undefined,
45
+ updatedAt: undefined,
46
+ lastRef: undefined,
47
+ seenGoalIds: new Set(),
48
+ }
49
+ }
50
+
51
+ /** Whether a value is a JSON record rather than an array. */
52
+ function isRecord(value: unknown): value is Record<string, unknown> {
53
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
54
+ }
55
+
56
+ /** Require one positive safe integer. */
57
+ function positiveInteger(value: unknown, field: string): number {
58
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
59
+ throw new Error(`goal change ${field} must be a positive safe integer`)
60
+ }
61
+ return value
62
+ }
63
+
64
+ /** Require one non-negative safe integer. */
65
+ function nonNegativeInteger(value: unknown, field: string): number {
66
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
67
+ throw new Error(`goal change ${field} must be a non-negative safe integer`)
68
+ }
69
+ return value
70
+ }
71
+
72
+ /** Decode one canonical blocker explanation. */
73
+ function decodeBlockReason(value: unknown): GoalBlockReason {
74
+ if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'code,message') {
75
+ throw new Error('goal change goal.blockedReason must have exactly code and message fields')
76
+ }
77
+ if (typeof value['code'] !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value['code'])) {
78
+ throw new Error('goal change goal.blockedReason.code must be lower-kebab-case')
79
+ }
80
+ if (typeof value['message'] !== 'string' || value['message'].trim().length === 0
81
+ || value['message'] !== value['message'].trim()) {
82
+ throw new Error('goal change goal.blockedReason.message must be non-empty and normalized')
83
+ }
84
+ return { code: value['code'], message: value['message'] }
85
+ }
86
+
87
+ /** Decode and validate one snapshot. */
88
+ function decodeSnapshot(value: unknown): GoalSnapshot {
89
+ if (!isRecord(value)) throw new Error('goal change goal must be a record')
90
+ if (typeof value['id'] !== 'string' || value['id'].length === 0) {
91
+ throw new Error('goal change goal.id must be a non-empty string')
92
+ }
93
+ if (typeof value['objective'] !== 'string' || value['objective'].trim().length === 0
94
+ || value['objective'] !== value['objective'].trim()) {
95
+ throw new Error('goal change goal.objective must be non-empty and normalized')
96
+ }
97
+ if (typeof value['phase'] !== 'string' || !PHASES.has(value['phase'] as GoalPhase)) {
98
+ throw new Error('goal change goal.phase is invalid')
99
+ }
100
+ const phase = value['phase'] as GoalPhase
101
+ const expectedKeys = phase === 'blocked'
102
+ ? 'blockedReason,id,maxGoalRounds,objective,phase,revision'
103
+ : 'id,maxGoalRounds,objective,phase,revision'
104
+ if (Object.keys(value).sort().join(',') !== expectedKeys) {
105
+ throw new Error(`goal change goal for phase ${phase} must have exactly ${expectedKeys} fields`)
106
+ }
107
+ return {
108
+ id: GoalId(value['id']),
109
+ revision: positiveInteger(value['revision'], 'goal.revision'),
110
+ objective: value['objective'],
111
+ phase,
112
+ maxGoalRounds: positiveInteger(value['maxGoalRounds'], 'goal.maxGoalRounds'),
113
+ ...phase === 'blocked' ? { blockedReason: decodeBlockReason(value['blockedReason']) } : {},
114
+ }
115
+ }
116
+
117
+ /** Decode and validate one ref. */
118
+ function decodeRef(value: unknown): GoalRef {
119
+ if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'id,revision') {
120
+ throw new Error('goal clear tombstone must have exactly id and revision fields')
121
+ }
122
+ if (typeof value['id'] !== 'string' || value['id'].length === 0) {
123
+ throw new Error('goal clear tombstone id must be a non-empty string')
124
+ }
125
+ return { id: GoalId(value['id']), revision: positiveInteger(value['revision'], 'cleared.revision') }
126
+ }
127
+
128
+ /**
129
+ * Decode a value that declares itself as a goal change. Unrelated values
130
+ * return `undefined`; malformed goal changes fail replay loudly.
131
+ * @param value - candidate source change.
132
+ * @returns validated goal change or `undefined` for another value kind.
133
+ */
134
+ export function decodeGoalChange(value: unknown): GoalChangeMeta | undefined {
135
+ if (!isRecord(value) || value['kind'] !== 'goal/change') return undefined
136
+ if (value['version'] !== GOAL_CHANGE_VERSION) {
137
+ throw new Error(`unsupported goal change version ${String(value['version'])}`)
138
+ }
139
+ if (value['operation'] === 'clear') {
140
+ const allowed = ['cleared', 'clearedAt', 'kind', 'operation', 'version']
141
+ if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) {
142
+ throw new Error(`goal clear change must have exactly ${allowed.sort().join(',')} fields`)
143
+ }
144
+ return {
145
+ kind: 'goal/change',
146
+ version: GOAL_CHANGE_VERSION,
147
+ operation: 'clear',
148
+ cleared: decodeRef(value['cleared']),
149
+ clearedAt: nonNegativeInteger(value['clearedAt'], 'clearedAt'),
150
+ } satisfies GoalClearChangeMeta
151
+ }
152
+ if (typeof value['operation'] !== 'string'
153
+ || !SNAPSHOT_OPERATIONS.has(value['operation'] as Exclude<GoalOperation, 'clear'>)) {
154
+ throw new Error('goal change operation is invalid')
155
+ }
156
+ const allowed = ['createdAt', 'goal', 'kind', 'operation', 'roundsStarted', 'updatedAt', 'version']
157
+ if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) {
158
+ throw new Error(`goal snapshot change must have exactly ${allowed.sort().join(',')} fields`)
159
+ }
160
+ const createdAt = nonNegativeInteger(value['createdAt'], 'createdAt')
161
+ const updatedAt = nonNegativeInteger(value['updatedAt'], 'updatedAt')
162
+ if (updatedAt < createdAt) throw new Error('goal change updatedAt cannot precede createdAt')
163
+ return {
164
+ kind: 'goal/change',
165
+ version: GOAL_CHANGE_VERSION,
166
+ operation: value['operation'] as Exclude<GoalOperation, 'clear'>,
167
+ goal: decodeSnapshot(value['goal']),
168
+ roundsStarted: nonNegativeInteger(value['roundsStarted'], 'roundsStarted'),
169
+ createdAt,
170
+ updatedAt,
171
+ } satisfies GoalSnapshotChangeMeta
172
+ }
173
+
174
+ /** Narrow model attribution to a valid goal source. */
175
+ function goalSource(source: MessageSource): GoalMessageSource | undefined {
176
+ if (source.kind !== 'goal') return undefined
177
+ if (typeof source.goalId !== 'string' || source.goalId.length === 0
178
+ || !Number.isSafeInteger(source.revision) || source.revision < 1
179
+ || !Number.isSafeInteger(source.round) || source.round < 1) {
180
+ throw new Error('goal message source is invalid')
181
+ }
182
+ return source
183
+ }
184
+
185
+ /** Require two snapshots to retain fields that only `edit` may replace. */
186
+ function requireSameDefinition(current: GoalSnapshot, next: GoalSnapshot, operation: GoalOperation): void {
187
+ if (next.objective !== current.objective || next.maxGoalRounds !== current.maxGoalRounds) {
188
+ throw new Error(`goal ${operation} cannot change objective or maxGoalRounds`)
189
+ }
190
+ }
191
+
192
+ /** Require one exact next revision of the current goal. */
193
+ function requireNextRevision(current: GoalSnapshot, next: GoalRef, operation: GoalOperation): void {
194
+ if (next.id !== current.id || next.revision !== current.revision + 1) {
195
+ throw new Error(`goal ${operation} must advance the current goal by one revision`)
196
+ }
197
+ }
198
+
199
+ /** Validate one non-create snapshot operation against the preceding projection. */
200
+ function validateSnapshotTransition(
201
+ state: GoalFoldState,
202
+ change: GoalSnapshotChangeMeta,
203
+ current: GoalSnapshot,
204
+ ): void {
205
+ const next = change.goal
206
+ requireNextRevision(current, next, change.operation)
207
+ /* v8 ignore next -- a current goal established by this fold always has an updatedAt */
208
+ if (state.updatedAt === undefined) throw new Error('current goal fold lacks updatedAt')
209
+ if (change.createdAt !== state.createdAt
210
+ || change.updatedAt < state.updatedAt
211
+ || change.roundsStarted !== state.roundsStarted) {
212
+ throw new Error(`goal ${change.operation} does not preserve the current counters and timestamps`)
213
+ }
214
+ switch (change.operation) {
215
+ case 'edit':
216
+ if (next.phase !== current.phase
217
+ || JSON.stringify(next.blockedReason) !== JSON.stringify(current.blockedReason)) {
218
+ throw new Error('goal edit cannot change phase or blocked reason')
219
+ }
220
+ break
221
+ case 'pause':
222
+ requireSameDefinition(current, next, change.operation)
223
+ if (current.phase !== 'active' || next.phase !== 'paused') throw new Error('goal pause has an invalid phase transition')
224
+ break
225
+ case 'resume': {
226
+ requireSameDefinition(current, next, change.operation)
227
+ const resumable: ReadonlySet<GoalPhase> = new Set([
228
+ 'active',
229
+ 'paused',
230
+ 'blocked',
231
+ ])
232
+ if (!resumable.has(current.phase) || next.phase !== 'active' || state.roundsStarted >= next.maxGoalRounds) {
233
+ throw new Error('goal resume has an invalid phase transition or exhausted round budget')
234
+ }
235
+ break
236
+ }
237
+ case 'complete':
238
+ requireSameDefinition(current, next, change.operation)
239
+ if (current.phase === 'complete' || next.phase !== 'complete') throw new Error('goal complete has an invalid phase transition')
240
+ break
241
+ case 'block':
242
+ requireSameDefinition(current, next, change.operation)
243
+ if (current.phase !== 'active' || next.phase !== 'blocked') throw new Error('goal block has an invalid phase transition')
244
+ break
245
+ /* v8 ignore start -- the caller excludes create and GoalOperation is closed; these arms retain fail-loud exhaustiveness */
246
+ case 'create':
247
+ throw new Error('goal create cannot be validated as a current-goal transition')
248
+ default:
249
+ change.operation satisfies never
250
+ throw new Error('unknown goal snapshot operation')
251
+ /* v8 ignore stop */
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Return the revision identity carried by a snapshot or tombstone.
257
+ * @param change - decoded goal mutation.
258
+ * @returns stable identity used to reconcile a deferred change with its log event.
259
+ */
260
+ export function goalChangeRef(change: GoalChangeMeta): GoalRef {
261
+ return change.operation === 'clear'
262
+ ? change.cleared
263
+ : { id: change.goal.id, revision: change.goal.revision }
264
+ }
265
+
266
+ /**
267
+ * Validate and apply one decoded change to a mutable accumulator.
268
+ * @param state - preceding durable goal projection.
269
+ * @param change - decoded full snapshot or clear tombstone.
270
+ */
271
+ export function applyGoalChange(state: GoalFoldState, change: GoalChangeMeta): void {
272
+ const ref = goalChangeRef(change)
273
+ if (change.operation === 'clear') {
274
+ const current = state.goal
275
+ if (current === undefined) throw new Error('goal clear requires a current goal')
276
+ requireNextRevision(current, change.cleared, change.operation)
277
+ /* v8 ignore next -- a current goal established by this fold always has an updatedAt */
278
+ if (state.updatedAt === undefined) throw new Error('current goal fold lacks updatedAt')
279
+ if (change.clearedAt < state.updatedAt) {
280
+ throw new Error('goal clear timestamp cannot precede the current goal update')
281
+ }
282
+ state.goal = undefined
283
+ state.roundsStarted = 0
284
+ state.createdAt = undefined
285
+ state.updatedAt = undefined
286
+ state.lastRef = ref
287
+ return
288
+ }
289
+ if (change.operation === 'create') {
290
+ if (change.goal.revision !== 1 || change.goal.phase !== 'active' || change.roundsStarted !== 0
291
+ || (state.goal !== undefined && state.goal.phase !== 'complete')
292
+ || state.seenGoalIds.has(change.goal.id)) {
293
+ throw new Error('goal create requires a fresh active revision-one goal with zero rounds')
294
+ }
295
+ state.seenGoalIds.add(change.goal.id)
296
+ } else {
297
+ const current = state.goal
298
+ if (current === undefined) throw new Error(`goal ${change.operation} requires a current goal`)
299
+ validateSnapshotTransition(state, change, current)
300
+ }
301
+ state.goal = change.goal
302
+ state.roundsStarted = change.roundsStarted
303
+ state.createdAt = change.createdAt
304
+ state.updatedAt = change.updatedAt
305
+ state.lastRef = ref
306
+ }
307
+
308
+ /**
309
+ * Apply one session event to the strict durable goal fold.
310
+ * @param state - mutable fold accumulator.
311
+ * @param event - next event in sequence order.
312
+ */
313
+ export function applyGoalEvent(state: GoalFoldState, event: SessionEvent): void {
314
+ if (event.type === 'goal/change') {
315
+ const change = decodeGoalChange(event.data)
316
+ /* v8 ignore next -- the event's declared payload always identifies itself as a goal change. */
317
+ if (change === undefined) throw new Error(`goal change at session event ${event.seq} has an invalid kind`)
318
+ applyGoalChange(state, change)
319
+ return
320
+ }
321
+ if (event.type === 'user/message') {
322
+ const source = goalSource(event.data.source)
323
+ if (source === undefined) return
324
+ const current = state.goal
325
+ if (current === undefined || current.phase !== 'active' || source.goalId !== current.id
326
+ || source.revision !== current.revision || source.round !== state.roundsStarted + 1
327
+ || source.round > current.maxGoalRounds) {
328
+ throw new Error(`goal round at session event ${event.seq} is not the next admitted round of the active goal`)
329
+ }
330
+ state.roundsStarted = source.round
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Fold current goal state from a contiguous session event log.
336
+ * @param events - session events in sequence order.
337
+ * @returns a fresh durable projection; activation is deliberately absent.
338
+ */
339
+ export function foldGoal(events: readonly SessionEvent[]): FoldedGoal {
340
+ const state = emptyGoalFoldState()
341
+ for (const event of events) applyGoalEvent(state, event)
342
+ return {
343
+ ...state.goal === undefined ? {} : { goal: { ...state.goal } },
344
+ roundsStarted: state.roundsStarted,
345
+ ...state.createdAt === undefined ? {} : { createdAt: state.createdAt },
346
+ ...state.updatedAt === undefined ? {} : { updatedAt: state.updatedAt },
347
+ ...state.lastRef === undefined ? {} : { lastRef: { ...state.lastRef } },
348
+ }
349
+ }