Przeglądaj źródła

feat(goal): split the pure type outlet and register the 'goal' projection unit

dsh-goal/types becomes the client-safe pure outlet (tool-todo dual-outlet
shape): GoalId/GoalRef/GoalPhase/GoalBlockReason/GoalSnapshot, the new
GoalProjection payload, and the SessionProjectionMap 'goal' key merge — zero
host imports. Host-coupled vocabulary (GoalView/activation, change metas,
message source, folds, GoalError codes, the scoped goal/changed event) moves
to src/domain.ts, re-exported from the package root. ./client re-exports the
outlet for client aggregates.

GoalService registers the projection unit under ctx.inject(['sessionProjections']):
applyGoalProjection is a projection-grade last-wins fold — plain-JSON state,
same-reference return on non-goal or malformed events (a throwing apply
would tear down the registry drive; strict validation stays with the write
side and foldGoal). Activation is process-local and deliberately absent from
the projection value.
imccyu 1 miesiąc temu
rodzic
commit
c991f76cef

+ 13 - 1
packages/goal/goal/package.json

@@ -15,12 +15,21 @@
       "types": "./lib/types/invariant.d.ts",
       "default": "./lib/invariant.js"
     },
+    "./types": {
+      "types": "./lib/types/types.d.ts",
+      "default": "./lib/types/types.js"
+    },
+    "./client": {
+      "types": "./lib/types/client.d.ts",
+      "default": "./lib/types/client.js"
+    },
     "./src/*": "./src/*",
     "./package.json": "./package.json"
   },
   "files": [
     "lib/index.js",
     "lib/invariant.js",
+    "lib/types/**/*.js",
     "lib/types/**/*.d.ts",
     "lib/types/**/*.d.ts.map",
     "src"
@@ -28,6 +37,7 @@
   "license": "BSD-3-Clause",
   "peerDependencies": {
     "@deepseek-ai/dsh-agent": "^0.0.1",
+    "@deepseek-ai/dsh-session-projection": "^0.0.1",
     "@deepseek-ai/dsh-brand": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
     "@deepseek-ai/dsh-llm": "^0.0.1",
@@ -36,10 +46,12 @@
     "cordis": "^4.0.0-rc.7"
   },
   "dependencies": {
-    "schemastery": "^3.17.2"
+    "schemastery": "^3.17.2",
+    "zod": "^4.4.3"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-brand": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",

+ 10 - 0
packages/goal/goal/src/client.ts

@@ -0,0 +1,10 @@
+/**
+ * Client-namespace projection of the goal domain: a pure re-export of the
+ * package's types outlet. Client code imports ONLY the client namespace
+ * (repo discipline), so `./client` projects the same single-source content
+ * `./types` serves to host consumers — zero duplication.
+ *
+ * @module @deepseek-ai/dsh-goal/client
+ */
+
+export type * from './types.ts'

+ 137 - 0
packages/goal/goal/src/domain.ts

@@ -0,0 +1,137 @@
+/**
+ * Host-side vocabulary of the goal domain: live views, durable change
+ * payloads, message attribution, replay folds, and the scoped `goal/changed`
+ * event. Split from ./types.ts (the pure client-safe outlet) because these
+ * declarations pull dsh-agent, dsh-llm, and cordis into the program — the
+ * one-program-per-side layout forbids that on client aggregates.
+ * @module @deepseek-ai/dsh-goal
+ */
+
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { GoalId, GoalRef, GoalSnapshot } from './types.ts'
+
+/** Whether this live process may automatically continue an active goal. */
+export type GoalActivation = 'armed' | 'disarmed'
+
+/** Current goal projection, including values derived from the session log. */
+export interface GoalView extends GoalSnapshot {
+  /** Highest admitted round number for this goal. */
+  readonly roundsStarted: number
+  /** Epoch milliseconds of the create mutation. */
+  readonly createdAt: number
+  /** Epoch milliseconds of the latest mutation. */
+  readonly updatedAt: number
+  /** Process-local continuation eligibility; never persisted. */
+  readonly activation: GoalActivation
+}
+
+/** Goal state-changing verbs recorded in the durable source change. */
+export type GoalOperation =
+  | 'create'
+  | 'edit'
+  | 'pause'
+  | 'resume'
+  | 'complete'
+  | 'block'
+  | 'clear'
+
+/** Full-snapshot goal mutation retained in a model-visible context event. */
+export interface GoalSnapshotChangeMeta {
+  readonly kind: 'goal/change'
+  readonly version: 1
+  readonly operation: Exclude<GoalOperation, 'clear'>
+  readonly goal: GoalSnapshot
+  readonly roundsStarted: number
+  readonly createdAt: number
+  readonly updatedAt: number
+}
+
+/** Tombstone retained when the current goal is cleared. */
+export interface GoalClearChangeMeta {
+  readonly kind: 'goal/change'
+  readonly version: 1
+  readonly operation: 'clear'
+  readonly cleared: GoalRef
+  readonly clearedAt: number
+}
+
+/** Durable change union carried by a goal-owned round-zero message source. */
+export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
+
+/** Message attribution for durable goal state and continuation rounds. */
+export interface GoalMessageSource {
+  readonly kind: 'goal'
+  readonly goalId: GoalId
+  readonly revision: number
+  /** Zero for state changes; positive for admitted continuation rounds. */
+  readonly round: number
+  /** Complete durable mutation carried only by round-zero state-change messages. */
+  readonly change?: GoalChangeMeta
+}
+
+declare module '@deepseek-ai/dsh-llm' {
+  interface MessageSourceMap {
+    goal: GoalMessageSource
+  }
+}
+
+/** Pure replay fold of durable goal facts. */
+export interface FoldedGoal {
+  /** Current goal, absent after a clear or before the first create. */
+  readonly goal?: GoalSnapshot
+  /** Highest admitted round for the current goal. */
+  readonly roundsStarted: number
+  /** Current goal creation time, absent without a current goal. */
+  readonly createdAt?: number
+  /** Current goal mutation time, absent without a current goal. */
+  readonly updatedAt?: number
+  /** Latest mutation ref, including a clear tombstone. */
+  readonly lastRef?: GoalRef
+}
+
+/** Input whose omitted round cap is resolved by the service configuration. */
+export interface CreateGoalRequest {
+  readonly objective: string
+  readonly maxGoalRounds?: number
+}
+
+/** Fields changed by an edit; at least one must be present. */
+export interface EditGoalRequest {
+  readonly objective?: string
+  readonly maxGoalRounds?: number
+}
+
+/** Live notification after one goal mutation has been accepted for logging. */
+export interface GoalChanged {
+  readonly operation: GoalOperation
+  readonly ref: GoalRef
+  /** Absent for a clear tombstone. */
+  readonly goal?: GoalView
+}
+
+/** Stable error codes for rejected goal reads and mutations. */
+export type GoalErrorCode =
+  | 'GOAL_AGENT_NOT_LIVE'
+  | 'GOAL_NOT_FOUND'
+  | 'GOAL_ALREADY_EXISTS'
+  | 'GOAL_STALE_REVISION'
+  | 'GOAL_INVALID_OBJECTIVE'
+  | 'GOAL_INVALID_MAX_ROUNDS'
+  | 'GOAL_INVALID_BLOCK_REASON'
+  | 'GOAL_INVALID_EDIT'
+  | 'GOAL_INVALID_TRANSITION'
+
+declare module 'cordis' {
+  interface Events {
+    /**
+     * Goal mutation accepted by one live agent. The matching context event is
+     * already appended or queued in that agent's active tool-batch FIFO.
+     * Listener failures are contained.
+     * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
+     * @param agent - agent whose session owns the goal.
+     * @param change - fresh current projection or clear tombstone.
+     * @mode emit
+     */
+    'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, agent: Agent, change: GoalChanged): void
+  }
+}

+ 2 - 5
packages/goal/goal/src/fold.ts

@@ -4,18 +4,15 @@ import type { MessageSource } from '@deepseek-ai/dsh-llm'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import { renderGoalChange } from './render.ts'
 import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts'
+import type { GoalBlockReason, GoalPhase, GoalRef, GoalSnapshot } from './types.ts'
 import type {
   FoldedGoal,
-  GoalBlockReason,
   GoalChangeMeta,
   GoalClearChangeMeta,
   GoalMessageSource,
   GoalOperation,
-  GoalPhase,
-  GoalRef,
-  GoalSnapshot,
   GoalSnapshotChangeMeta,
-} from './types.ts'
+} from './domain.ts'
 
 type UserMessageEvent = Extract<SessionEvent, { type: 'user/message' }>
 

+ 79 - 8
packages/goal/goal/src/index.ts

@@ -7,10 +7,14 @@
 import { randomUUID } from 'node:crypto'
 import { Context, Service } from 'cordis'
 import z from 'schemastery'
+import { z as zod } from 'zod'
+import type { ZodType } from 'zod'
 import { agentEvents } from '@deepseek-ai/dsh-agent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
-import type { Session } from '@deepseek-ai/dsh-session'
+import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+// Type-only: resolves ctx.sessionProjections for the optional unit child.
+import type {} from '@deepseek-ai/dsh-session-projection'
 import {
   applyGoalChange,
   applyGoalEvent,
@@ -25,23 +29,31 @@ import {
   GoalError,
   GoalId,
 } from './runtime.ts'
+import type {
+  GoalBlockReason,
+  GoalPhase,
+  GoalProjection,
+  GoalRef,
+  GoalSnapshot,
+} from './types.ts'
 import type {
   CreateGoalRequest,
   EditGoalRequest,
   GoalActivation,
-  GoalBlockReason,
   GoalChangeMeta,
   GoalChanged,
   GoalClearChangeMeta,
   GoalOperation,
-  GoalPhase,
-  GoalRef,
-  GoalSnapshot,
   GoalSnapshotChangeMeta,
   GoalView,
-} from './types.ts'
-
-export * from './types.ts'
+} from './domain.ts'
+
+// The pure payload outlet (./types.ts, ONE home of the `goal` projection-key
+// declaration) re-exported onto the package root keeps the module edge in
+// the emitted index.d.ts, so aggregate programs consuming the declarations
+// still receive the SessionProjectionMap merge.
+export type * from './types.ts'
+export type * from './domain.ts'
 export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts'
 export { decodeGoalChange, foldGoal, goalChangeRef } from './fold.ts'
 export { renderGoalChange } from './render.ts'
@@ -52,6 +64,52 @@ declare module 'cordis' {
   }
 }
 
+/** Wire payload schema of the `goal` projection (whole current goal or pre-create/cleared null). */
+const goalProjectionSchema: ZodType<GoalProjection | null> = zod.union([
+  zod.object({
+    goal: zod.object({
+      id: zod.string().min(1),
+      revision: zod.number().int().positive(),
+      objective: zod.string().min(1),
+      phase: zod.union([zod.literal('active'), zod.literal('paused'), zod.literal('blocked'), zod.literal('complete')]),
+      blockedReason: zod.object({ code: zod.string(), message: zod.string() }).optional(),
+      maxGoalRounds: zod.number().int().positive(),
+    }),
+    roundsStarted: zod.number().int().nonnegative(),
+    createdAt: zod.number(),
+    updatedAt: zod.number(),
+  }),
+  zod.null(),
+]) as ZodType<GoalProjection | null>
+
+/**
+ * Light last-wins fold of the `goal` projection unit. Unlike the strict
+ * replay fold (fold.ts: transition validation, fail-loud on malformed
+ * changes, Set-typed state), this transition is projection-grade: the state
+ * is plain JSON (persisted-cache precondition), any non-goal or malformed
+ * event returns the same reference (the registry's Object.is gate — the
+ * title/todos posture), and correctness of the written change is the write
+ * side's job (GoalService validated it before appending; the package
+ * invariant rejects a violating stream fail-loud where it is installed).
+ * @param state - the projection covering all prior events.
+ * @param event - the next committed session event.
+ * @returns the next projection (same reference when the event is not a goal change).
+ */
+export function applyGoalProjection(state: GoalProjection | null, event: SessionEvent): GoalProjection | null {
+  if (event.type !== 'user/message') return state
+  const source = event.data.source
+  if (source.kind !== 'goal' || source.round !== 0) return state
+  const change = source.change
+  if (change === undefined || change.kind !== 'goal/change') return state
+  if (change.operation === 'clear') return null
+  return {
+    goal: change.goal,
+    roundsStarted: change.roundsStarted,
+    createdAt: change.createdAt,
+    updatedAt: change.updatedAt,
+  }
+}
+
 /** Deployment defaults for goal creation. */
 export interface Config {
   /** Total rounds used when a create request omits its own cap. */
@@ -150,6 +208,19 @@ export class GoalService extends Service {
     ctx.on('agent/session-start', (agent) => {
       this.cache(agent.session).activation = 'disarmed'
     })
+    // The `goal` projection unit: last-wins fold of goal/change whole values
+    // (see applyGoalProjection). The unit child activates only when a
+    // projection registry is composed (headless assemblies stay unaffected).
+    ctx.inject(['sessionProjections'], (projectionCtx) => {
+      projectionCtx.sessionProjections.register<'goal', GoalProjection | null>({
+        key: 'goal',
+        schema: goalProjectionSchema,
+        init: () => null,
+        apply: applyGoalProjection,
+        view: state => state,
+        stateVersion: 1,
+      })
+    })
   }
 
   /**

+ 1 - 1
packages/goal/goal/src/render.ts

@@ -1,7 +1,7 @@
 /** Model-visible rendering for durable goal mutations. */
 
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
-import type { GoalChangeMeta } from './types.ts'
+import type { GoalChangeMeta } from './domain.ts'
 
 /**
  * Render a complete goal snapshot or clear tombstone without hidden prose.

+ 2 - 1
packages/goal/goal/src/runtime.ts

@@ -1,7 +1,8 @@
 /** Runtime constructors and protocol constants for the goal domain. */
 
 import { HarnessError } from '@deepseek-ai/dsh-llm'
-import type { GoalErrorCode, GoalId as GoalIdType } from './types.ts'
+import type { GoalId as GoalIdType } from './types.ts'
+import type { GoalErrorCode } from './domain.ts'
 
 /** Version of the goal change embedded in a round-zero message source. */
 export const GOAL_CHANGE_VERSION = 1

+ 24 - 115
packages/goal/goal/src/types.ts

@@ -1,10 +1,16 @@
 /**
- * Durable and live vocabulary for one same-session goal.
+ * Pure types of the goal domain: the ONE home of the `goal` projection-key
+ * declaration plus the durable payload vocabulary it carries, free of this
+ * package's host-side imports (cordis events, dsh-agent, dsh-llm, the
+ * service). Two namespace projections serve it — `./types` for host
+ * consumers, `./client` (the browser half-entry's re-export) for client
+ * aggregates — with zero content duplication. Host-coupled domain
+ * vocabulary (message sources, events, fold shapes) lives in ./domain.ts.
+ *
  * @module @deepseek-ai/dsh-goal/types
  */
 
 import type { Branded } from '@deepseek-ai/dsh-brand'
-import type { Agent } from '@deepseek-ai/dsh-agent'
 
 /** Identifies one goal across its durable revisions. */
 export type GoalId = Branded<'GoalId'>
@@ -44,128 +50,31 @@ export interface GoalSnapshot extends GoalRef {
   readonly maxGoalRounds: number
 }
 
-/** Whether this live process may automatically continue an active goal. */
-export type GoalActivation = 'armed' | 'disarmed'
-
-/** Current goal projection, including values derived from the session log. */
-export interface GoalView extends GoalSnapshot {
+/**
+ * The `goal` projection value: the current durable goal with its replay
+ * counters, exactly as the latest `goal/change` source carried them.
+ * Activation is process-local (never persisted) and deliberately absent —
+ * the projection reflects durable phase only.
+ */
+export interface GoalProjection {
+  /** Current durable goal snapshot (the CAS ref for mutations rides on it). */
+  readonly goal: GoalSnapshot
   /** Highest admitted round number for this goal. */
   readonly roundsStarted: number
   /** Epoch milliseconds of the create mutation. */
   readonly createdAt: number
   /** Epoch milliseconds of the latest mutation. */
   readonly updatedAt: number
-  /** Process-local continuation eligibility; never persisted. */
-  readonly activation: GoalActivation
-}
-
-/** Goal state-changing verbs recorded in the durable source change. */
-export type GoalOperation =
-  | 'create'
-  | 'edit'
-  | 'pause'
-  | 'resume'
-  | 'complete'
-  | 'block'
-  | 'clear'
-
-/** Full-snapshot goal mutation retained in a model-visible context event. */
-export interface GoalSnapshotChangeMeta {
-  readonly kind: 'goal/change'
-  readonly version: 1
-  readonly operation: Exclude<GoalOperation, 'clear'>
-  readonly goal: GoalSnapshot
-  readonly roundsStarted: number
-  readonly createdAt: number
-  readonly updatedAt: number
-}
-
-/** Tombstone retained when the current goal is cleared. */
-export interface GoalClearChangeMeta {
-  readonly kind: 'goal/change'
-  readonly version: 1
-  readonly operation: 'clear'
-  readonly cleared: GoalRef
-  readonly clearedAt: number
-}
-
-/** Durable change union carried by a goal-owned round-zero message source. */
-export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta
-
-/** Message attribution for durable goal state and continuation rounds. */
-export interface GoalMessageSource {
-  readonly kind: 'goal'
-  readonly goalId: GoalId
-  readonly revision: number
-  /** Zero for state changes; positive for admitted continuation rounds. */
-  readonly round: number
-  /** Complete durable mutation carried only by round-zero state-change messages. */
-  readonly change?: GoalChangeMeta
-}
-
-declare module '@deepseek-ai/dsh-llm' {
-  interface MessageSourceMap {
-    goal: GoalMessageSource
-  }
-}
-
-/** Pure replay fold of durable goal facts. */
-export interface FoldedGoal {
-  /** Current goal, absent after a clear or before the first create. */
-  readonly goal?: GoalSnapshot
-  /** Highest admitted round for the current goal. */
-  readonly roundsStarted: number
-  /** Current goal creation time, absent without a current goal. */
-  readonly createdAt?: number
-  /** Current goal mutation time, absent without a current goal. */
-  readonly updatedAt?: number
-  /** Latest mutation ref, including a clear tombstone. */
-  readonly lastRef?: GoalRef
 }
 
-/** Input whose omitted round cap is resolved by the service configuration. */
-export interface CreateGoalRequest {
-  readonly objective: string
-  readonly maxGoalRounds?: number
-}
-
-/** Fields changed by an edit; at least one must be present. */
-export interface EditGoalRequest {
-  readonly objective?: string
-  readonly maxGoalRounds?: number
-}
-
-/** Live notification after one goal mutation has been accepted for logging. */
-export interface GoalChanged {
-  readonly operation: GoalOperation
-  readonly ref: GoalRef
-  /** Absent for a clear tombstone. */
-  readonly goal?: GoalView
-}
-
-/** Stable error codes for rejected goal reads and mutations. */
-export type GoalErrorCode =
-  | 'GOAL_AGENT_NOT_LIVE'
-  | 'GOAL_NOT_FOUND'
-  | 'GOAL_ALREADY_EXISTS'
-  | 'GOAL_STALE_REVISION'
-  | 'GOAL_INVALID_OBJECTIVE'
-  | 'GOAL_INVALID_MAX_ROUNDS'
-  | 'GOAL_INVALID_BLOCK_REASON'
-  | 'GOAL_INVALID_EDIT'
-  | 'GOAL_INVALID_TRANSITION'
-
-declare module 'cordis' {
-  interface Events {
+declare module '@deepseek-ai/dsh-session-projection/types' {
+  interface SessionProjectionMap {
     /**
-     * Goal mutation accepted by one live agent. The matching context event is
-     * already appended or queued in that agent's active tool-batch FIFO.
-     * Listener failures are contained.
-     * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-     * @param agent - agent whose session owns the goal.
-     * @param change - fresh current projection or clear tombstone.
-     * @mode emit
+     * The session's current goal (the latest `goal/change` whole value), or
+     * `null` before the first create and after a clear tombstone.
+     * Whole-value rule: every goal change carries the complete post-change
+     * state, so the fold is last-wins.
      */
-    'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, agent: Agent, change: GoalChanged): void
+    goal: GoalProjection | null
   }
 }

+ 161 - 0
packages/goal/goal/tests/projection.spec.ts

@@ -0,0 +1,161 @@
+/**
+ * The `goal` projection unit: mounting GoalService beside the registry
+ * serves the current whole goal on the history tail page with a consistent
+ * asOfSeq; before the first create the value is null; a clear tombstone
+ * returns it to null; a composition without the goal service has no `goal`
+ * key; unmounting drops it (HMR safety). Malformed goal-shaped events are
+ * ignored fail-soft (same-reference return) — strict replay validation
+ * belongs to the write side and foldGoal, never the projection drive.
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import type { UserMessage } from '@deepseek-ai/dsh-session'
+import SessionStore from '@deepseek-ai/dsh-session'
+import type { Session } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import GoalService, { applyGoalProjection } from '@deepseek-ai/dsh-goal'
+import type { GoalRef } from '@deepseek-ai/dsh-goal'
+
+interface Bench {
+  ctx: Context
+  session: Session
+  agent: Agent
+  tailValues(): Record<string, unknown>
+  tailAsOfSeq(): number
+}
+
+/** Register a minimal registry-compatible live agent over a store session. */
+function liveAgent(ctx: Context, session: Session): Agent {
+  const status: AgentStatus = 'idle'
+  const agent: Agent = {
+    id: session.id,
+    options: {},
+    session,
+    ctx,
+    get status() { return status },
+    get acceptsNextStep() { return false },
+    send: () => {},
+    followup: () => {},
+    steer: () => {},
+    inject(input: UserMessage) {
+      session.append('user/message', input, { surfaceOp: 'append' })
+    },
+    cancel() {},
+    whenIdle() { return Promise.resolve() },
+  } as Agent
+  ctx.agents.register(agent)
+  return agent
+}
+
+async function harness(withGoal: boolean): Promise<Bench> {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(SessionProjectionRegistry)
+  if (withGoal) await ctx.plugin(GoalService)
+  const session = ctx.sessions.create()
+  const agent = liveAgent(ctx, session)
+  return {
+    ctx,
+    session,
+    agent,
+    tailValues: () => ctx.sessionProjections.snapshot(session).values as Record<string, unknown>,
+    tailAsOfSeq: () => ctx.sessionProjections.snapshot(session).asOfSeq,
+  }
+}
+
+/** One paginable message so the tail is non-degenerate. */
+function seedMessage(session: Session): void {
+  session.append('user/message', createUserMessage({
+    content: [{ type: 'text', text: 'hi' }],
+    source: { kind: 'user' },
+  }), { surfaceOp: 'append' })
+}
+
+describe('goal projection unit', () => {
+  it('serves null before the first create', async () => {
+    const bench = await harness(true)
+    seedMessage(bench.session)
+    expect(bench.tailValues()).toEqual({ goal: null })
+    expect(bench.tailAsOfSeq()).toBe(bench.session.seq - 1)
+  })
+
+  it('serves the whole current goal after create and tracks mutations last-wins', async () => {
+    vi.useFakeTimers()
+    vi.setSystemTime(1_700_000_000_000)
+    try {
+      const bench = await harness(true)
+      seedMessage(bench.session)
+      const created = bench.ctx.goals.create(bench.agent, { objective: 'ship the goal bar' })
+      const afterCreate = bench.tailValues().goal
+      expect(afterCreate).toMatchObject({
+        goal: { id: created.id, revision: 1, objective: 'ship the goal bar', phase: 'active' },
+        roundsStarted: 0,
+      })
+
+      const ref: GoalRef = { id: created.id, revision: created.revision }
+      const paused = bench.ctx.goals.pause(bench.agent, ref)
+      expect(bench.tailValues().goal).toMatchObject({
+        goal: { revision: paused.revision, phase: 'paused' },
+      })
+      expect(bench.tailAsOfSeq()).toBe(bench.session.seq - 1)
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
+  it('returns to null after a clear tombstone', async () => {
+    vi.useFakeTimers()
+    vi.setSystemTime(1_700_000_000_000)
+    try {
+      const bench = await harness(true)
+      seedMessage(bench.session)
+      const created = bench.ctx.goals.create(bench.agent, { objective: 'temporary' })
+      expect(bench.tailValues().goal).not.toBeNull()
+      bench.ctx.goals.clear(bench.agent, { id: created.id, revision: created.revision })
+      expect(bench.tailValues().goal).toBeNull()
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
+  it('ignores non-goal and malformed goal-shaped events fail-soft (same reference)', () => {
+    // The package invariant rejects a violating stream loudly wherever it is
+    // installed — the unit itself must never throw on the projection drive
+    // (a throwing apply would tear down every registered unit's drive), so
+    // its transition is exercised directly as the pure function it is.
+    const user = { type: 'user/message', seq: 0, time: 1, data: createUserMessage({
+      content: [{ type: 'text', text: 'hi' }],
+      source: { kind: 'user' },
+    }) } as never
+    expect(applyGoalProjection(null, user)).toBeNull()
+
+    const malformed = { type: 'user/message', seq: 1, time: 2, data: createUserMessage({
+      content: [{ type: 'text', text: 'broken' }],
+      source: { kind: 'goal', goalId: 'g-broken', revision: 1, round: 0 } as never,
+    }) } as never
+    const state = { goal: { id: 'g1', revision: 1, objective: 'x', phase: 'active', maxGoalRounds: 4 }, roundsStarted: 0, createdAt: 1, updatedAt: 1 } as never
+    // Same-reference return: the registry's Object.is gate sees no change.
+    expect(applyGoalProjection(state, malformed)).toBe(state)
+    expect(applyGoalProjection(null, malformed)).toBeNull()
+  })
+
+  it('has no goal key when the goal service is not composed', async () => {
+    const bench = await harness(false)
+    seedMessage(bench.session)
+    expect('goal' in (bench.tailValues() ?? {})).toBe(false)
+  })
+
+  it('drops the key when the goal fiber unloads (HMR safety)', async () => {
+    const bench = await harness(false)
+    seedMessage(bench.session)
+    const fiber = await bench.ctx.plugin(GoalService)
+    expect(bench.tailValues()).toEqual({ goal: null })
+    await fiber.dispose()
+    expect('goal' in (bench.tailValues() ?? {})).toBe(false)
+  })
+})

+ 3 - 0
packages/goal/goal/tsconfig.json

@@ -32,6 +32,9 @@
     {
       "path": "../../core/agent"
     },
+    {
+      "path": "../../session-projection/session-projection"
+    },
     {
       "path": "../../support/invariants"
     }

+ 9 - 0
pnpm-lock.yaml

@@ -2419,6 +2419,9 @@ importers:
       schemastery:
         specifier: ^3.17.2
         version: 3.18.0
+      zod:
+        specifier: ^4.4.3
+        version: 4.4.3
     devDependencies:
       '@deepseek-ai/dsh-agent':
         specifier: workspace:^
@@ -2441,6 +2444,9 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-session-projection':
+        specifier: workspace:^
+        version: link:../../session-projection/session-projection
       cordis:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
@@ -2670,6 +2676,9 @@ importers:
       '@deepseek-ai/dsh-commands':
         specifier: workspace:^
         version: link:../../ui/commands
+      '@deepseek-ai/dsh-goal':
+        specifier: workspace:^
+        version: link:../../goal/goal
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm

+ 2 - 0
tsconfig.base.json

@@ -46,6 +46,8 @@
       "@deepseek-ai/dsh-tool-todo/client": ["./packages/todo/tool-todo/src/client.ts"],
       "@deepseek-ai/dsh-session-title/types": ["./packages/session-title/session-title/src/types.ts"],
       "@deepseek-ai/dsh-session-title/client": ["./packages/session-title/session-title/src/client.ts"],
+      "@deepseek-ai/dsh-goal/types": ["./packages/goal/goal/src/types.ts"],
+      "@deepseek-ai/dsh-goal/client": ["./packages/goal/goal/src/client.ts"],
       "@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"],
       "@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.ts"],
       "@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"],