diff --git a/docs/subsystems/goal.i18n.yaml b/docs/subsystems/goal.i18n.yaml
index 3e48766d14..df731a5dba 100644
--- a/docs/subsystems/goal.i18n.yaml
+++ b/docs/subsystems/goal.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/goal.md
-goal.md: e5c8d77e7a897b097e7801f65024612d233319a9
-goal.zh.md: a4c8f26173a1ed4b991beaa02de7894258f50b6d
+goal.md: 676b2f49d00681ac7f05b894cda0157ebba4cfcd
+goal.zh.md: 1d46bc2c9ba1f3b35026ff132d323e46ccd3bc19
diff --git a/docs/subsystems/goal.md b/docs/subsystems/goal.md
index e5c8d77e7a..676b2f49d0 100644
--- a/docs/subsystems/goal.md
+++ b/docs/subsystems/goal.md
@@ -247,7 +247,7 @@ block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView
Types: [Agent](core.md)
-Source: [`packages/goal/goal/src/index.ts:182`](../../packages/goal/goal/src/index.ts)
+Source: [`packages/goal/goal/src/index.ts:183`](../../packages/goal/goal/src/index.ts)
diff --git a/docs/subsystems/goal.zh.md b/docs/subsystems/goal.zh.md
index a4c8f26173..1d46bc2c9b 100644
--- a/docs/subsystems/goal.zh.md
+++ b/docs/subsystems/goal.zh.md
@@ -247,7 +247,7 @@ block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView
Types: [Agent](core.md)
-Source: [`packages/goal/goal/src/index.ts:182`](../../packages/goal/goal/src/index.ts)
+Source: [`packages/goal/goal/src/index.ts:183`](../../packages/goal/goal/src/index.ts)
diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml
index 33f7103a9e..5331ba0581 100644
--- a/docs/subsystems/session-projection.i18n.yaml
+++ b/docs/subsystems/session-projection.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
-session-projection.md: a0704206904880259425b6b32b648d383d815fb2
-session-projection.zh.md: 74392356d980e83b1ca281732b916f7d9e678702
+session-projection.md: edc1d646c01cb95ccc090f69f3868e466c346fdf
+session-projection.zh.md: 9c10737ef8b6d409155039632da86f0573c05f13
diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts
index 045c773bcd..baf8c921a9 100644
--- a/packages/goal/goal/src/index.ts
+++ b/packages/goal/goal/src/index.ts
@@ -13,6 +13,7 @@ import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
+// Type-only: resolves ctx.sessionProjections for the optional unit child.
import type {} from '@deepseek-ai/dsh-session-projection'
import {
applyGoalEvent,
@@ -49,7 +50,7 @@ import type {
// 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 SessionProjectionStateMap merge.
+// still receive the SessionProjectionMap merge.
export type * from './types.ts'
export type * from './domain.ts'
export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts'
@@ -180,7 +181,7 @@ function resolveBlockReason(reason: unknown): GoalBlockReason {
/** Goal service (`ctx.goals`) backed exclusively by the owning session log. */
export class GoalService extends TypertRemoteService {
- static inject = ['agents', 'sessionProjections']
+ static inject = ['agents']
static Config: z = z.object({
defaultMaxGoalRounds: z.number().default(256),
@@ -197,13 +198,18 @@ export class GoalService extends TypertRemoteService {
ctx.on('agent/session-start', ({ agent }) => {
this.cache(agent.session).activation = 'disarmed'
})
- ctx.sessionProjections.register<'goal', GoalProjection | null>({
- key: 'goal',
- stateVersion: 4,
- stateSchema: goalProjectionSchema,
- init: () => null,
- apply: applyGoalProjection,
- wire: { viewSchema: goalProjectionSchema, view: state => state },
+ // 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',
+ stateSchema: goalProjectionSchema,
+ init: () => null,
+ apply: applyGoalProjection,
+ wire: { viewSchema: goalProjectionSchema, view: state => state },
+ stateVersion: 4,
+ })
})
}
diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts
index 1eb0c90d2e..e94e0754a0 100644
--- a/packages/goal/goal/tests/goal.spec.ts
+++ b/packages/goal/goal/tests/goal.spec.ts
@@ -4,7 +4,6 @@ import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
-import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import GoalService, {
GoalError,
GoalId,
@@ -61,7 +60,6 @@ function stubAgent(rawId: string, seed?: readonly import('@deepseek-ai/dsh-sessi
async function harness(config: { defaultMaxGoalRounds?: number } = {}) {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
- await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(GoalService, config)
const stub = stubAgent(`goal-test-${Math.random()}`)
ctx.agents.register(stub.agent)
@@ -133,7 +131,6 @@ describe('GoalService creation and replay', () => {
it('also resolves the default when constructed directly without Cordis config normalization', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
- await ctx.plugin(SessionProjectionRegistry)
const goals = new GoalService(ctx)
const stub = stubAgent('goal-direct-construction')
ctx.agents.register(stub.agent)
@@ -145,7 +142,6 @@ describe('GoalService creation and replay', () => {
it('rejects invalid direct configuration', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
- await ctx.plugin(SessionProjectionRegistry)
await expect(ctx.plugin(GoalService, { defaultMaxGoalRounds: -1 })).rejects.toThrow(expect.objectContaining({
code: 'GOAL_INVALID_MAX_ROUNDS',
}))
@@ -159,7 +155,6 @@ describe('GoalService creation and replay', () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
- await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(GoalService)
const resumed = stubAgent('seeded-goal', first.session.events)
ctx.agents.register(resumed.agent)
@@ -174,7 +169,6 @@ describe('GoalService creation and replay', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
- await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(GoalService)
const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent')))
ctx.agents.register(parent.agent)
@@ -221,7 +215,6 @@ describe('GoalService creation and replay', () => {
it('removes the service and its session-start listener with the providing fiber', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
- await ctx.plugin(SessionProjectionRegistry)
const fiber = await ctx.plugin(GoalService)
const first = ctx.goals
const stub = stubAgent('goal-hmr')
@@ -430,7 +423,6 @@ describe('GoalService mutations', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
- await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(GoalService)
const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer')))
ctx.agents.register(stub.agent)
@@ -449,7 +441,6 @@ describe('GoalService mutations', () => {
it('does not delegate goal persistence to agent injection', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
- await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(GoalService)
const stub = stubAgent('goal-independent-injection')
stub.agent.inject = () => { throw new Error('injection must not be called') }
diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts
index e5fb642e84..6282a6f739 100644
--- a/packages/goal/goal/tests/projection.spec.ts
+++ b/packages/goal/goal/tests/projection.spec.ts
@@ -1,4 +1,12 @@
-/** Goal projection behavior. */
+/**
+ * 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 '@deepseek-ai/cordis'
diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts
index 67b7f523ca..11822ce4af 100644
--- a/packages/interaction/permission-presets/src/index.ts
+++ b/packages/interaction/permission-presets/src/index.ts
@@ -208,13 +208,17 @@ export class PermissionPresetService extends Service {
})),
currentValue: zod.string().min(1),
}) as unknown as zod.ZodType
- ctx.sessionProjections.register({
- key: 'permissions',
- stateVersion: 1,
- stateSchema: knobStateSchema,
- init: () => EMPTY_KNOBS,
- apply: applyKnobEvent,
- wire: { viewSchema: selectSchema, view: state => this.selectFor(state) },
+ // The `permissions` projection unit folds the three whole-value knob
+ // events; it registers through the projection registry.
+ ctx.inject(['sessionProjections'], (projectionCtx) => {
+ projectionCtx.sessionProjections.register({
+ key: 'permissions',
+ stateVersion: 1,
+ stateSchema: knobStateSchema,
+ init: () => EMPTY_KNOBS,
+ apply: applyKnobEvent,
+ wire: { viewSchema: selectSchema, view: state => this.selectFor(state) },
+ })
})
// The /permission command: the one write path a web client uses (the
diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts
index 676baef697..4a8924d0b7 100644
--- a/packages/plan/plan-mode/src/index.ts
+++ b/packages/plan/plan-mode/src/index.ts
@@ -217,7 +217,10 @@ export class PlanModeController extends Service {
},
})
- ctx.sessionProjections.register(planProjectionDefinition)
+ // The `plan` projection unit registers through the projection registry.
+ ctx.inject(['sessionProjections'], (projectionCtx) => {
+ projectionCtx.sessionProjections.register(planProjectionDefinition)
+ })
// The command child activates only when a command registry is composed.
ctx.inject(['commands'], (commandCtx) => {