Skip to content

Commit 5cde1b1

Browse files
deepracticexsclaude
andcommitted
refactor: role rich domain model in core + protocol export
Role is now a rich domain model in @rolexjs/core with ownership isolation, KV-serializable snapshot/restore, and all domain methods. RoleXService orchestrates lifecycle. Prototype merged into core. Protocol interface bundles tools + instructions for channel adapters. rolexjs becomes thin rendering shell. Minimal public API surface. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b4f08af commit 5cde1b1

96 files changed

Lines changed: 4896 additions & 1123 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/role-rich-model.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@rolexjs/core": minor
3+
"rolexjs": minor
4+
"@rolexjs/mcp-server": patch
5+
---
6+
7+
Role rich domain model — merge prototype into core, Protocol export
8+
9+
- Role is now a rich domain model in @rolexjs/core with ownership isolation, KV-serializable snapshot/restore, and all domain methods (want, plan, todo, finish, reflect, realize, master, etc.)
10+
- RoleXService orchestrates Role lifecycle, caching, and persistence in core
11+
- rolexjs becomes a thin rendering shell delegating to core's RoleXService
12+
- Protocol interface bundles tools + instructions as a single export for channel adapters
13+
- Removed scattered exports (render, genesis, createRendererRouter) from rolexjs public API
14+
- Deleted old Role class and RoleContext from rolexjs (replaced by core's Role)
15+
- Moved findInState utility to core

apps/mcp-server/src/index.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@ import {
1616
detail,
1717
type ParamDef,
1818
type ProjectAction,
19+
protocol,
1920
renderProjectResult,
2021
type State,
2122
type ToolDef,
22-
tools,
23-
worldInstructions,
2423
} from "rolexjs";
2524

2625
import { z } from "zod";
@@ -185,11 +184,11 @@ const executors: Record<string, ToolExecutor> = {
185184
const server = new FastMCP({
186185
name: "rolex",
187186
version: "0.12.0",
188-
instructions: worldInstructions,
187+
instructions: protocol.instructions,
189188
});
190189

191190
// Register all tools from unified schema
192-
for (const toolDef of tools) {
191+
for (const toolDef of protocol.tools) {
193192
const executor = executors[toolDef.name];
194193
if (!executor) {
195194
throw new Error(`No executor for tool "${toolDef.name}"`);

apps/mcp-server/tests/mcp.test.ts

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
/**
22
* MCP server integration tests.
33
*
4-
* Tests the thin MCP layer (state holder) on top of Rolex.
5-
* Business logic (RoleContext) is tested in rolexjs/tests/context.test.ts.
4+
* Tests the thin MCP layer (state holder) on top of RoleX.
5+
* Business logic (Role model) is tested in core/tests/unit/role-model.test.ts.
66
* Render is now in rolexjs — Role methods return rendered strings directly.
77
*/
88
import { beforeEach, describe, expect, it } from "bun:test";
9+
import type { CommandResult } from "@rolexjs/core";
910
import { localPlatform } from "@rolexjs/local-platform";
10-
import type { CommandResult } from "@rolexjs/prototype";
11-
import { createRoleX, type Rolex, render } from "rolexjs";
11+
import { createRoleX, type RoleX } from "rolexjs";
12+
import { render } from "../../../packages/rolexjs/src/render.js";
1213
import { McpState } from "../src/state.js";
1314

14-
let rolex: Rolex;
15+
let rolex: RoleX;
1516
let state: McpState;
1617

1718
beforeEach(async () => {
@@ -33,7 +34,7 @@ describe("requireRole", () => {
3334
const role = await rolex.activate("sean");
3435
state.role = role;
3536
expect(state.requireRole()).toBe(role);
36-
expect(state.requireRole().roleId).toBe("sean");
37+
expect(state.requireRole().id).toBe("sean");
3738
});
3839
});
3940

@@ -104,19 +105,18 @@ describe("render", () => {
104105

105106
describe("full execution flow", () => {
106107
it("completes want → plan → todo → finish → reflect → realize through Role API", async () => {
107-
// Born + activate
108108
await rolex.direct("!individual.born", { content: "Feature: Sean", id: "sean" });
109109
const role = await rolex.activate("sean");
110110
state.role = role;
111111

112112
// Want
113113
const goal = await role.want("Feature: Build Auth", "build-auth");
114-
expect(role.ctx.focusedGoalId).toBe("build-auth");
114+
expect(role.snapshot().focusedGoalId).toBe("build-auth");
115115
expect(goal).toContain("I →");
116116

117117
// Plan
118118
const plan = await role.plan("Feature: Auth Plan", "auth-plan");
119-
expect(role.ctx.focusedPlanId).toBe("auth-plan");
119+
expect(role.snapshot().focusedPlanId).toBe("auth-plan");
120120
expect(plan).toContain("I →");
121121

122122
// Todo
@@ -129,7 +129,7 @@ describe("full execution flow", () => {
129129
"Feature: Implemented JWT\n Scenario: Token pattern\n Given JWT needed\n Then tokens work"
130130
);
131131
expect(finished).toContain("[encounter]");
132-
expect(role.ctx.encounterIds.has("impl-jwt-finished")).toBe(true);
132+
expect(role.snapshot().encounterIds).toContain("impl-jwt-finished");
133133

134134
// Reflect: encounter → experience
135135
const reflected = await role.reflect(
@@ -138,8 +138,8 @@ describe("full execution flow", () => {
138138
"token-insight"
139139
);
140140
expect(reflected).toContain("[experience]");
141-
expect(role.ctx.encounterIds.has("impl-jwt-finished")).toBe(false);
142-
expect(role.ctx.experienceIds.has("token-insight")).toBe(true);
141+
expect(role.snapshot().encounterIds).not.toContain("impl-jwt-finished");
142+
expect(role.snapshot().experienceIds).toContain("token-insight");
143143

144144
// Realize: experience → principle
145145
const realized = await role.realize(
@@ -148,7 +148,7 @@ describe("full execution flow", () => {
148148
"refresh-tokens"
149149
);
150150
expect(realized).toContain("[principle]");
151-
expect(role.ctx.experienceIds.has("token-insight")).toBe(false);
151+
expect(role.snapshot().experienceIds).not.toContain("token-insight");
152152
});
153153
});
154154

@@ -163,14 +163,14 @@ describe("focus", () => {
163163
state.role = role;
164164

165165
await role.want("Feature: Goal A", "goal-a");
166-
expect(role.ctx.focusedGoalId).toBe("goal-a");
166+
expect(role.snapshot().focusedGoalId).toBe("goal-a");
167167

168168
await role.want("Feature: Goal B", "goal-b");
169-
expect(role.ctx.focusedGoalId).toBe("goal-b");
169+
expect(role.snapshot().focusedGoalId).toBe("goal-b");
170170

171171
// Switch back to goal A
172172
await role.focus("goal-a");
173-
expect(role.ctx.focusedGoalId).toBe("goal-a");
173+
expect(role.snapshot().focusedGoalId).toBe("goal-a");
174174
});
175175
});
176176

@@ -179,18 +179,16 @@ describe("focus", () => {
179179
// ================================================================
180180

181181
describe("selective cognition", () => {
182-
it("ctx tracks multiple encounters, reflect consumes selectively", async () => {
182+
it("tracks multiple encounters, reflect consumes selectively", async () => {
183183
await rolex.direct("!individual.born", { content: "Feature: Sean", id: "sean" });
184184
const role = await rolex.activate("sean");
185185
state.role = role;
186186

187-
// Create goal + plan + tasks
188187
await role.want("Feature: Auth", "auth");
189188
await role.plan("Feature: Plan", "plan1");
190189
await role.todo("Feature: Login", "login");
191190
await role.todo("Feature: Signup", "signup");
192191

193-
// Finish both with encounters
194192
await role.finish(
195193
"login",
196194
"Feature: Login done\n Scenario: OK\n Given login\n Then success"
@@ -200,8 +198,8 @@ describe("selective cognition", () => {
200198
"Feature: Signup done\n Scenario: OK\n Given signup\n Then success"
201199
);
202200

203-
expect(role.ctx.encounterIds.has("login-finished")).toBe(true);
204-
expect(role.ctx.encounterIds.has("signup-finished")).toBe(true);
201+
expect(role.snapshot().encounterIds).toContain("login-finished");
202+
expect(role.snapshot().encounterIds).toContain("signup-finished");
205203

206204
// Reflect only on "login-finished"
207205
await role.reflect(
@@ -211,9 +209,9 @@ describe("selective cognition", () => {
211209
);
212210

213211
// "login-finished" consumed, "signup-finished" still available
214-
expect(role.ctx.encounterIds.has("login-finished")).toBe(false);
215-
expect(role.ctx.encounterIds.has("signup-finished")).toBe(true);
216-
// Experience registered
217-
expect(role.ctx.experienceIds.has("login-insight")).toBe(true);
212+
const snap = role.snapshot();
213+
expect(snap.encounterIds).not.toContain("login-finished");
214+
expect(snap.encounterIds).toContain("signup-finished");
215+
expect(snap.experienceIds).toContain("login-insight");
218216
});
219217
});

bdd/features/role-domain.feature

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
@role-domain
2+
Feature: Role as rich domain model
3+
Role is a self-contained stateful entity that holds its own state projection
4+
and exposes domain behaviors. Internal state (cursors, cognitive registries)
5+
is not exposed — only domain methods.
6+
7+
Background:
8+
Given a fresh Rolex instance
9+
And individual "sean" exists
10+
And I activate role "sean"
11+
12+
# ===== Role boundary =====
13+
14+
Scenario: Role knows its own boundary
15+
Given I want goal "auth" with "Feature: Auth"
16+
And I plan "jwt" with "Feature: JWT"
17+
And I todo "login" with "Feature: Login"
18+
Then role "sean" should contain node "auth"
19+
And role "sean" should contain node "jwt"
20+
And role "sean" should contain node "login"
21+
22+
# ===== serialization =====
23+
24+
Scenario: Role serializes cursor and cognitive state
25+
Given I want goal "auth" with "Feature: Auth"
26+
And I plan "jwt" with "Feature: JWT"
27+
And I todo "login" with "Feature: Login"
28+
And I finish "login" with encounter "Feature: Login done\n Scenario: OK\n Given login\n Then success"
29+
When I serialize the role
30+
Then the snapshot should contain focusedGoalId "auth"
31+
And the snapshot should contain focusedPlanId "jwt"
32+
And the snapshot should contain encounter "login-finished"
33+
34+
Scenario: Role restores from snapshot and state projection
35+
Given I want goal "auth" with "Feature: Auth"
36+
And I plan "jwt" with "Feature: JWT"
37+
And I serialize the role
38+
When I restore the role from snapshot with fresh state projection
39+
Then focusedGoalId should be "auth"
40+
And focusedPlanId should be "jwt"
41+
And focus without args should return "auth"
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
@role-isolation
2+
Feature: Role isolation
3+
Each Role is a self-contained operation domain for one individual.
4+
Focus, goals, plans, and cognitive state must never leak between individuals.
5+
6+
Background:
7+
Given a fresh Rolex instance
8+
And individual "nuwa" exists with goal "setup-cto"
9+
And individual "sean" exists with goal "mcp-args"
10+
11+
# ===== focus isolation =====
12+
13+
Scenario: Focus without args returns own goal
14+
When I activate role "nuwa"
15+
And I focus without args
16+
Then focusedGoalId should be "setup-cto"
17+
And the output should contain "(setup-cto)"
18+
19+
Scenario: Focus cannot target another individual's goal
20+
When I activate role "nuwa"
21+
Then focus on "mcp-args" should fail with ownership error
22+
23+
Scenario: Switching individuals restores correct focus
24+
Given I activate role "sean"
25+
And I focus on "mcp-args"
26+
When I activate role "nuwa"
27+
And I focus without args
28+
Then focusedGoalId should be "setup-cto"
29+
30+
# ===== goal isolation =====
31+
32+
Scenario: Want creates goal under own individual only
33+
When I activate role "nuwa"
34+
And I want goal "new-goal" with "Feature: New goal"
35+
Then "new-goal" should be under individual "nuwa"
36+
And "new-goal" should not be under individual "sean"
37+
38+
# ===== persistence isolation =====
39+
40+
Scenario: Persisted focus does not leak across individuals
41+
Given I activate role "sean"
42+
And I focus on "mcp-args"
43+
And role "sean" is persisted
44+
When I activate role "nuwa"
45+
And I focus without args
46+
Then focusedGoalId should be "setup-cto"
47+
And focusedGoalId should not be "mcp-args"
48+
49+
Scenario: Restore from KV returns correct individual state
50+
Given I activate role "sean"
51+
And I focus on "mcp-args"
52+
And role "sean" is persisted
53+
When I restore role "sean" from KV
54+
Then focusedGoalId should be "mcp-args"
55+
When I restore role "nuwa" from KV
56+
Then focusedGoalId should be "setup-cto"

bdd/features/rolex-world.feature

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
@rolex-world
2+
Feature: RoleX as world entry point
3+
RoleX is the runtime that manages Role lifecycle and world-level operations.
4+
activate produces a Role instance. direct executes world-level commands.
5+
6+
Background:
7+
Given a fresh Rolex instance
8+
9+
# ===== activate =====
10+
11+
Scenario: Activate returns a Role instance
12+
Given individual "sean" exists
13+
When I activate role "sean"
14+
Then I should receive a Role with id "sean"
15+
16+
Scenario: Activate same individual returns same instance
17+
Given individual "sean" exists
18+
When I activate role "sean"
19+
And I activate role "sean" again
20+
Then both activations should return the same Role instance
21+
22+
Scenario: Activate non-existent individual fails
23+
When I try to activate role "unknown"
24+
Then it should fail with "not found"
25+
26+
# ===== direct =====
27+
28+
Scenario: Direct executes world-level commands
29+
When I direct "!individual.born" with:
30+
| id | alice |
31+
| content | Feature: Alice |
32+
Then individual "alice" should exist
33+
34+
Scenario: Direct does not require an active Role
35+
Given individual "sean" exists
36+
When I direct "!census.list" with:
37+
| type | individual |
38+
Then the direct result should contain "sean"

bdd/steps/context.steps.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ When("I activate {string}", async function (this: BddWorld, name: string) {
7575
Then("focusedGoalId should be {string}", function (this: BddWorld, goalId: string) {
7676
assert.ok(this.role, "Expected a role but activation failed");
7777
assert.equal(
78-
this.role.ctx.focusedGoalId,
78+
this.role.snapshot().focusedGoalId,
7979
goalId,
80-
`Expected focusedGoalId to be "${goalId}" but got "${this.role.ctx.focusedGoalId}"`
80+
`Expected focusedGoalId to be "${goalId}" but got "${this.role.snapshot().focusedGoalId}"`
8181
);
8282
});

0 commit comments

Comments
 (0)