@cratis/pi 7.28.0 → 7.29.0

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.
package/README.md CHANGED
@@ -29,6 +29,24 @@ yields to the managed installation to avoid duplicate resources and hooks. The P
29
29
  package manages only Pi; it neither configures other harnesses nor owns a local
30
30
  managed corpus.
31
31
 
32
+ ## Screenplay MCP tools
33
+
34
+ The packaged `cratis-mcp` extension starts the Screenplay MCP server
35
+ (`cratis screenplay mcp`) when the resolved profiles include `cratis/screenplay`.
36
+ The backend application profiles compose it, so an application repository gets
37
+ the Screenplay skills and, when the Cratis CLI 3.46.1 or later is installed, the
38
+ Screenplay tools; the server creates no model folder until the first write.
39
+ Installing them does not opt the repository in to model-first work.
40
+
41
+ When Screenplay comes in only through such a profile and no model root is
42
+ configured, a missing CLI, a CLI older than 3.46.1 (which creates the model
43
+ folder at server start) or a server that fails to start leaves the Screenplay
44
+ tools inactive with one informational notice when Pi has a UI, and silently otherwise.
45
+ Selecting any profile that also reaches Screenplay without going through an application
46
+ profile (including `cratis/screenplay`, `cratis/modeling/screenplay-stage`,
47
+ `cratis/full/*` or `cratis`), or configuring `mcpServers.screenplay.root` in
48
+ `.cratis/ai.json`, keeps startup failures visible as errors.
49
+
32
50
  ## Path guidance and subagents
33
51
 
34
52
  The packaged `cratis-path-guidance` extension attaches a path-scoped rule to the
@@ -69,14 +69,21 @@ export function selectedServer(project: string, corpus: string) {
69
69
  });
70
70
  const languages = new Set(strings(configuration.languages));
71
71
  const selected = new Set<string>();
72
- const select = (id: string, explicit: boolean): void => {
73
- if (selected.has(id)) return;
72
+ const visited = new Set<string>();
73
+ let nonApplicationSelection = false;
74
+ const select = (id: string, explicit: boolean, throughApplication = false): void => {
74
75
  const profile = profiles.find(candidate => candidate.id === id);
75
76
  if (!profile) throw new Error(`Unknown Cratis AI profile '${id}'.`);
76
77
  const supported = strings(profile.languages);
77
78
  if (!explicit && supported.length && !supported.some(language => language === 'language-agnostic' || languages.has(language))) return;
79
+ const applicationPath = throughApplication || id === 'cratis/application' || id.startsWith('cratis/application/');
80
+ // Visit both paths: application composition must not hide an existing, independently selected Screenplay path.
81
+ const key = JSON.stringify([id, applicationPath]);
82
+ if (visited.has(key)) return;
83
+ visited.add(key);
78
84
  selected.add(id);
79
- strings(profile.composes).forEach(child => select(child, false));
85
+ if (id === 'cratis/screenplay' && !applicationPath) nonApplicationSelection = true;
86
+ strings(profile.composes).forEach(child => select(child, false, applicationPath));
80
87
  };
81
88
  requested.forEach(id => select(id, true));
82
89
  if (!selected.has('cratis/screenplay')) return undefined;
@@ -99,5 +106,9 @@ export function selectedServer(project: string, corpus: string) {
99
106
  const root = object(screenplay) ? screenplay.root : undefined;
100
107
  if (root !== undefined && typeof root !== 'string') throw new Error('Screenplay MCP root must be a project-relative string.');
101
108
  const physicalProject = realpathSync(project);
102
- return { project: physicalProject, root: root === undefined ? undefined : physicalRoot(physicalProject, root) };
109
+ return {
110
+ project: physicalProject,
111
+ root: root === undefined ? undefined : physicalRoot(physicalProject, root),
112
+ composedOnly: !nonApplicationSelection && root === undefined,
113
+ };
103
114
  }
@@ -7,19 +7,22 @@ import { fileURLToPath } from 'node:url';
7
7
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
8
8
  import { ConnectionFailure } from './ConnectionFailure.ts';
9
9
  import { object, selectedServer } from './configuration.ts';
10
- import { startConnection } from './process.ts';
10
+ import { startConnection, supportsNoModelStartup } from './process.ts';
11
11
  import { discover, failedResult, mapResult } from './protocol.ts';
12
+ import type { DiscoveredTool } from './DiscoveredTool.ts';
12
13
  import type { StdioConnection } from './StdioConnection.ts';
13
14
 
14
15
  const extensionDirectory = dirname(fileURLToPath(import.meta.url));
15
16
  const bundledCorpus = resolve(extensionDirectory, '..', '..', '..', '..');
16
17
 
17
18
  /** Native tools go through Pi's tool_call restrictions and normal execution/result pipeline. */
18
- export function registerBridge(pi: ExtensionAPI, corpus: string, connect = startConnection): void {
19
+ export function registerBridge(pi: ExtensionAPI, corpus: string, connect = startConnection, checkVersion = supportsNoModelStartup): void {
19
20
  let connection: StdioConnection | undefined;
20
21
  let selectionKey: string | undefined;
21
22
  let attempted = false;
22
23
  let uncertain = false;
24
+ let startupSupported: Promise<boolean> | undefined;
25
+ let unavailableReported = false;
23
26
  const registered = new Set<string>();
24
27
  const paused = new Set<string>();
25
28
  const failures = new Map<string, ReturnType<typeof failedResult>>();
@@ -47,6 +50,7 @@ export function registerBridge(pi: ExtensionAPI, corpus: string, connect = start
47
50
  if (!connection || connection.stopped) throw new Error('Screenplay connection is unavailable. No request was retried; explicitly reload after inspecting any uncertain mutation.');
48
51
  };
49
52
  const refresh = async (context: ExtensionContext, newSession = false): Promise<void> => {
53
+ if (newSession) { startupSupported = undefined; unavailableReported = false; }
50
54
  const previouslyActive = new Set([...pi.getActiveTools(), ...paused]);
51
55
  let selection: ReturnType<typeof selectedServer>;
52
56
  try { selection = currentSelection(context); }
@@ -60,11 +64,27 @@ export function registerBridge(pi: ExtensionAPI, corpus: string, connect = start
60
64
  if (!selection) return;
61
65
  if (uncertain) throw new Error('Screenplay mutation outcome remains unknown. Explicitly reload, inspect workspace-state, and obtain authorization before any recovery.');
62
66
  const modelRoot = selection.root ?? "the project's Screenplay model";
63
- const activeConnection = connect(selection.project);
64
- connection = activeConnection;
67
+ let activeConnection: StdioConnection;
68
+ let tools: DiscoveredTool[];
65
69
  try {
66
- const tools = await discover(activeConnection);
70
+ if (selection.composedOnly && !await (startupSupported ??= checkVersion(selection.project))) {
71
+ throw new Error('Screenplay optional startup requires Cratis CLI 3.46.1 or later.');
72
+ }
73
+ activeConnection = connect(selection.project);
74
+ connection = activeConnection;
75
+ tools = await discover(activeConnection);
67
76
  if (connection !== activeConnection || activeConnection.stopped) throw new Error('Screenplay session ended during discovery.');
77
+ } catch (error) {
78
+ dispose();
79
+ // Application-only composition makes MCP optional. Independent profile/root errors and uncertain mutations still surface.
80
+ if (!selection.composedOnly || uncertain) throw error;
81
+ if (!unavailableReported) {
82
+ unavailableReported = true;
83
+ if (context.hasUI) context.ui.notify('Screenplay MCP is unavailable; install/update the Cratis CLI to 3.46.1 or later to enable it.', 'info');
84
+ }
85
+ return;
86
+ }
87
+ try {
68
88
  const existing = new Set(pi.getAllTools().map(tool => tool.name));
69
89
  if (tools.some(tool => existing.has(tool.nativeName) && !registered.has(tool.nativeName))) throw new Error('Screenplay native tool name conflicts with another extension.');
70
90
  const oldNames = new Set(registered);
@@ -1,7 +1,7 @@
1
1
  // Copyright (c) Cratis. All rights reserved.
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
- import { spawn } from 'node:child_process';
4
+ import { execFile, spawn } from 'node:child_process';
5
5
  import { StdioConnection } from './StdioConnection.ts';
6
6
 
7
7
  /** Preserve OS/runtime lookup only; never copy arbitrary credentials or descriptor environment values. */
@@ -13,6 +13,27 @@ export function childEnvironment(source: NodeJS.ProcessEnv = process.env): NodeJ
13
13
  return environment;
14
14
  }
15
15
 
16
+ /** Older CLIs create a model root at server startup; optional composition must remain footprint-free. */
17
+ export function supportsNoModelStartup(project: string, execute: typeof execFile = execFile): Promise<boolean> {
18
+ return new Promise(resolve => {
19
+ execute('cratis', ['--version'], {
20
+ cwd: project,
21
+ env: childEnvironment(),
22
+ shell: false,
23
+ windowsHide: true,
24
+ timeout: 3000,
25
+ killSignal: 'SIGKILL',
26
+ maxBuffer: 4096,
27
+ encoding: 'utf8',
28
+ }, (error, stdout) => {
29
+ const version = /^\s*(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?\s*$/.exec(stdout);
30
+ if (error || !version) { resolve(false); return; }
31
+ const [, major, minor, patch] = version.map(Number);
32
+ resolve(major > 3 || (major === 3 && (minor > 46 || (minor === 46 && patch >= 1))));
33
+ });
34
+ });
35
+ }
36
+
16
37
  export function startConnection(project: string, launch: typeof spawn = spawn): StdioConnection {
17
38
  return new StdioConnection(launch('cratis', ['screenplay', 'mcp', '--project-root', project], {
18
39
  cwd: project,
@@ -51,16 +51,21 @@ The complete list of available profiles is defined in [profile-catalog.json](../
51
51
 
52
52
  | Profile ID | Description | Automatically Includes |
53
53
  |---|---|---|
54
- | `cratis/application` | Full application stack (C# + React + TypeScript) | `cratis/application/csharp`, `cratis/application/elixir`, `cratis/application/kotlin`, `cratis/application/typescript`, `cratis/arc/core`, `cratis/arc/react`, `cratis/chronicle/core`, `cratis/components`, `cratis/fundamentals`, `cratis/specifications/dotnet`, `cratis/specifications/typescript` |
55
- | `cratis/application/csharp` | C# backend with Arc + Chronicle | `cratis/arc`, `cratis/arc/react`, `cratis/chronicle`, `cratis/components`, `cratis/fundamentals`, `cratis/language/csharp`, `cratis/specifications/dotnet`, `cratis/specifications/typescript` |
54
+ | `cratis/application` | Full application stack (C# + React + TypeScript) | `cratis/application/csharp`, `cratis/application/elixir`, `cratis/application/java`, `cratis/application/kotlin`, `cratis/application/typescript`, `cratis/arc/core`, `cratis/arc/react`, `cratis/chronicle/core`, `cratis/components`, `cratis/fundamentals`, `cratis/specifications/dotnet`, `cratis/specifications/typescript` |
55
+ | `cratis/application/csharp` | C# backend with Arc + Chronicle | `cratis/arc`, `cratis/arc/react`, `cratis/chronicle`, `cratis/components`, `cratis/fundamentals`, `cratis/language/csharp`, `cratis/screenplay`, `cratis/specifications/dotnet`, `cratis/specifications/typescript` |
56
56
  | `cratis/application/react` | React frontend with Cratis Components | `cratis/arc/core`, `cratis/arc/react`, `cratis/components`, `cratis/fundamentals`, `cratis/specifications/dotnet`, `cratis/specifications/typescript` |
57
- | `cratis/application/typescript` | TypeScript client for Chronicle | `cratis/chronicle/client-typescript`, `cratis/language/typescript`, `cratis/specifications/typescript` |
58
- | `cratis/application/arc-chronicle` | Arc + Chronicle integration | `cratis/arc/core`, `cratis/chronicle/core`, `cratis/fundamentals`, `cratis/specifications/dotnet` |
59
- | `cratis/application/arc-only` | Arc without Chronicle | `cratis/arc/core`, `cratis/fundamentals`, `cratis/specifications/dotnet` |
60
- | `cratis/application/chronicle-dotnet` | Chronicle .NET client | `cratis/chronicle/client-dotnet`, `cratis/chronicle/core`, `cratis/fundamentals`, `cratis/specifications/dotnet` |
61
- | `cratis/application/elixir` | Elixir Chronicle client | `cratis/chronicle/client-elixir`, `cratis/language/elixir` |
62
- | `cratis/application/kotlin` | Kotlin Arc + Chronicle application | `cratis/arc/client-kotlin`, `cratis/chronicle/client-kotlin`, `cratis/language/kotlin` |
63
- | `cratis/application/java` | Java Arc + Chronicle application | `cratis/arc/client-kotlin`, `cratis/chronicle/client-java`, `cratis/language/java` |
57
+ | `cratis/application/typescript` | TypeScript client for Chronicle | `cratis/chronicle/client-typescript`, `cratis/language/typescript`, `cratis/screenplay`, `cratis/specifications/typescript` |
58
+ | `cratis/application/arc-chronicle` | Arc + Chronicle integration | `cratis/arc/core`, `cratis/chronicle/core`, `cratis/fundamentals`, `cratis/screenplay`, `cratis/specifications/dotnet` |
59
+ | `cratis/application/arc-only` | Arc without Chronicle | `cratis/arc/core`, `cratis/fundamentals`, `cratis/screenplay`, `cratis/specifications/dotnet` |
60
+ | `cratis/application/chronicle-dotnet` | Chronicle .NET client | `cratis/chronicle/client-dotnet`, `cratis/chronicle/core`, `cratis/fundamentals`, `cratis/screenplay`, `cratis/specifications/dotnet` |
61
+ | `cratis/application/elixir` | Elixir Chronicle client | `cratis/chronicle/client-elixir`, `cratis/language/elixir`, `cratis/screenplay` |
62
+ | `cratis/application/kotlin` | Kotlin Arc + Chronicle application | `cratis/arc/client-kotlin`, `cratis/chronicle/client-kotlin`, `cratis/language/kotlin`, `cratis/screenplay` |
63
+ | `cratis/application/java` | Java Arc + Chronicle application | `cratis/arc/client-kotlin`, `cratis/chronicle/client-java`, `cratis/language/java`, `cratis/screenplay` |
64
+
65
+ `cratis/application` inherits `cratis/screenplay` through its language-specific
66
+ children. Backend-bearing application profiles include the method skills and MCP
67
+ declaration, not `cratis/stage`; the frontend-only `cratis/application/react`
68
+ profile includes neither Screenplay nor Stage.
64
69
 
65
70
  ### Framework Profiles
66
71
 
@@ -179,7 +184,8 @@ or gap-fill from its contract; never edit Stage-managed output or weaken protect
179
184
  Otherwise stay code-first; only the entry-point session may propose a model, at most once
180
185
  per session. Master definition: `cratis-screenplay-modeling-lifecycle`. Framework and non-opted-in brownfield work remain
181
186
  code-first; infrastructure, clients, adapters and code attachments stay in code.
182
- Application profiles do not automatically compose `cratis/screenplay`.
187
+ Backend-bearing application profiles automatically compose `cratis/screenplay`;
188
+ installing its skills or registering its MCP server is not model-first opt-in.
183
189
 
184
190
  `cratis/screenplay` carries the **method** and the **language**. Load the lifecycle
185
191
  and one phase skill, then the construct skill needed for syntax.
@@ -228,6 +234,15 @@ conflicts. The model is wherever the project keeps its `.play` files, else under
228
234
  `src/`, else in a `Screenplay/` folder at the repository root; never under `.cratis/`.
229
235
  Project-owned configuration can name a root explicitly.
230
236
 
237
+ **No-model startup requires Cratis CLI 3.46.1 or later.** With
238
+ `cratis screenplay mcp --project-root <project>`, that version resolves the root
239
+ without creating it; discovery, startup and proposals leave no model folder, and
240
+ the first `apply` write creates it. `cratis ai install` also creates no model
241
+ folder unless the project configured a root. Older CLIs create the resolved model root at
242
+ server startup (`Screenplay/` when no `.play` files, `Source/` or `src/` exist); upgrade before using the automatically selected MCP server in a
243
+ repository without a model. In Pi, when every selected path to Screenplay goes through an application profile and no root is configured, a missing, older or unavailable CLI leaves its MCP tools inactive with one informational UI notice (none in headless mode); an independent non-application path to Screenplay or a configured root still surfaces startup errors. See the toolchain skill's
244
+ [version reference](../skills/cratis-screenplay-toolchain/references/versions.md).
245
+
231
246
  ## How to Use Profiles
232
247
 
233
248
  ### 1. Select Your Profile
@@ -268,7 +283,7 @@ Profiles can compose other profiles. When you select a parent profile, all child
268
283
  {
269
284
  "schemaVersion": "1.0.0",
270
285
  "profiles": [
271
- "cratis/application/csharp" // Includes: arc, arc/react, chronicle, components, fundamentals, language/csharp, specifications/dotnet, specifications/typescript
286
+ "cratis/application/csharp" // Includes: arc, arc/react, chronicle, components, fundamentals, language/csharp, screenplay, specifications/dotnet, specifications/typescript
272
287
  ]
273
288
  }
274
289
  ```
@@ -278,4 +278,34 @@ async Task Because() => await _scenario.Given.ForEventSource(_id).Events(new Inv
278
278
  [Fact] async Task should_notify_about_the_issued_invoice() => await _notifications.Received(1).Notify(Arg.Is<string>(_ => _.Contains("INV-1001")));
279
279
  ```
280
280
 
281
+ ### Read-model dependencies in scenarios — Chronicle 19.46.0+
282
+
283
+ `ReactorScenario<TReactor>` does **not** build read models from `Given` events,
284
+ including `[Passive]` models. Seed the handler's read-model parameter explicitly
285
+ before firing the trigger:
286
+
287
+ ```csharp
288
+ void Establish() => _scenario.Given.ForEventSourceId(_id).ReadModel(_order);
289
+ async Task Because() => await _scenario.Given.ForEventSource(_id).Events(_orderPlaced);
290
+ ```
291
+
292
+ In a separate absence specification, replace the seeding with
293
+ `_scenario.Given.ForEventSourceId(_id).NoReadModel<Order>()` to deliberately pass
294
+ `null` to an `Order?` parameter. Without either setup, resolution to `null` throws
295
+ `ReadModelNotSeededForReactorScenario` (`Cratis.Chronicle.Testing.Reactors`), even
296
+ for a nullable parameter. Its message names the type and missing key, suggests
297
+ both setup methods, and lists other seeded keys for that type when any exist.
298
+ Use the key from `ICanResolveReadModelKey.Resolve` for seeding or absence when
299
+ implemented, not the triggering source id. `NoReadModel<T>()` does not remove a
300
+ seeded instance. This guard applies to `EventStoreForTesting`, not an explicitly
301
+ supplied store of another type; production read-model resolution is unchanged.
302
+
303
+ Also in Chronicle 19.46.0+, `Events(...)` surfaces a synchronously throwing
304
+ handler's own exception, preserving its stack, rather than the reflection
305
+ `TargetInvocationException`. Capture it with `Catch.Exception` and assert the
306
+ handler's exception type. `EventContext.From(...)` now defaults an omitted
307
+ `occurred` to `DateTimeOffset.UtcNow`, not local `Now`; scenario contexts,
308
+ `EventContext.Empty` and `EmptyWithEventSourceId(...)` use that helper. An
309
+ explicit occurrence time is preserved, not normalized to UTC.
310
+
281
311
  For reactors that return side-effect events, assert the resulting appends through the scenario's event store; for non-event side effects, assert on the mocked services (as above). See [specs.scenarios.csharp.md](./specs.scenarios.csharp.md) for the full `*Scenario` family.
@@ -160,6 +160,47 @@ async Task Because() => await _scenario.Given.ForEventSource(_id).Events(new Aut
160
160
  [Fact] async Task should_notify() => await _service.Received(1).Notify("Jane Austen");
161
161
  ```
162
162
 
163
+ #### Read-model setup — Chronicle 19.46.0+
164
+
165
+ In a class-based `ReactorScenario<TReactor>`, `Given` events invoke the reactor;
166
+ they **do not materialize read models**, even `[Passive]` ones. For each
167
+ read-model handler parameter, choose one setup before `Because()`:
168
+
169
+ ```csharp
170
+ // Present-state specification: supply the instance the handler must receive.
171
+ void Establish() => _scenario.Given.ForEventSourceId(_id).ReadModel(_author);
172
+
173
+ // Separate absence specification: deliberately let an Author? parameter receive null.
174
+ void Establish() => _scenario.Given.ForEventSourceId(_id).NoReadModel<Author>();
175
+ ```
176
+
177
+ These are alternatives in **separate specification classes**, not two
178
+ `Establish()` methods in one class. Both builders return
179
+ `ReactorSourceGivenBuilder<TReactor>` for chaining; neither setup is async.
180
+ Use the triggering event-source id by default, or the key returned by the
181
+ reactor's `ICanResolveReadModelKey.Resolve` when implemented. An absence
182
+ declaration is for that exact type and key and does not delete a seeded model.
183
+
184
+ If a recognized read-model parameter resolves to `null` without declared absence,
185
+ `Events(...)` throws `ReadModelNotSeededForReactorScenario` from
186
+ `Cratis.Chronicle.Testing.Reactors`, even if the parameter is nullable. The
187
+ message names the full type and missing key, explains `ReadModel(...)` and
188
+ `NoReadModel<T>()`, and lists seeded keys for that type when any exist. Fix the
189
+ fixture or its key; do not add `NoReadModel<T>()` just to silence missing setup.
190
+ The guard applies to `EventStoreForTesting`; it does not alter production
191
+ resolution or an explicitly supplied event store of another type.
192
+
193
+ **Handler failures:** in Chronicle 19.46.0+, a synchronous handler throw surfaces
194
+ its own exception, not `TargetInvocationException`, with its handler stack
195
+ preserved. Use `await Catch.Exception(() => _scenario.Given.ForEventSource(_id).Events(...))`
196
+ and assert the handler's exception type.
197
+
198
+ **Occurrence time:** in Chronicle 19.46.0+, `EventContext.From(...)` uses
199
+ `DateTimeOffset.UtcNow` when `occurred` is omitted (previously local `Now`).
200
+ `ReactorScenario` builds its contexts through this helper, as do
201
+ `EventContext.Empty` and `EmptyWithEventSourceId(...)`. Explicit occurrence times
202
+ keep their supplied offset; the positional constructor has no such default.
203
+
163
204
  ## Out-of-process Chronicle integration specs (advanced)
164
205
 
165
206
  Reserve these for the host/transport boundary the scenario helpers can't reach. They test a complete slice — HTTP request → command → append → constraint → projection — against a real Chronicle store, and live under `when_<behavior>/` directly inside the slice folder (no `for_` folder; the "unit" is the whole slice).
@@ -30,6 +30,10 @@ This skill decides **when to add a scenario** and **what to cover**. The C# mech
30
30
 
31
31
  > Re-verified at the versions above by **symbol and signature**: every type, attribute and member this skill names exists at that tag, and the public surface it describes is unchanged since the previous verification (Chronicle 16.45.x / Arc 22.10.4 — the Chronicle 16→18 client diff is converters, options and doc comments; no type was removed or renamed). Behavior claims were verified at the earlier tag unless a section says otherwise.
32
32
 
33
+ Reactor read-model setup and the `EventContext.From` UTC default below are
34
+ verified separately at **Chronicle 19.46.0**, the minimum version for that
35
+ guidance; see `cratis-chronicle-reactor/references/specifications.md` for sources.
36
+
33
37
  `Cratis.Testing` is the meta-package that brings `Cratis.Arc.Testing` and
34
38
  `Cratis.Arc.Chronicle.Testing` together. Reverify against the owning product
35
39
  repository before claiming support for another version.
@@ -112,6 +116,15 @@ keep a scenario for that wiring and for appended-event behavior.
112
116
  | Automation or Translation — a reactor | `ReactorScenario<TReactor>` | `cratis-specifications-csharp` |
113
117
  | A host, transport, or real-infrastructure boundary | Out-of-process integration | `cratis-specifications-csharp` |
114
118
 
119
+ For a class-based reactor scenario on **Chronicle 19.46.0+**, explicitly seed
120
+ handler read-model parameters with `Given.ForEventSourceId(id).ReadModel(instance)`
121
+ or declare intentional absence with `.NoReadModel<T>()`. An unseeded model throws
122
+ `ReadModelNotSeededForReactorScenario` even if nullable; `Given` events do not
123
+ build read models, including passive ones. Use the reactor's resolved key, not
124
+ necessarily the trigger's source id. This is **not** the command-scenario event
125
+ seeding described in Step 5. Read `cratis-chronicle-reactor` for the setup examples,
126
+ exception diagnostics, synchronous handler throws and UTC context defaults.
127
+
115
128
  ## Step 3 — Cover every outcome of a State Change slice
116
129
 
117
130
  One specification class for **each** of:
@@ -45,6 +45,11 @@ This skill is verified against these exact sources:
45
45
  | `Cratis.Chronicle` | `18.3.0` | `Cratis.Chronicle.Reactors`, `Cratis.Chronicle.Reactors.SideEffects`, `Cratis.Chronicle.EventSequences`, `Cratis.Chronicle.Events` |
46
46
  | `Cratis.Chronicle.CodeAnalysis` | `18.3.0` | `CHR0004`, `CHR0005`, `CHR0008`, `CHR0013`, `CHR0022`, `CHR0031`, `CHR0032` |
47
47
 
48
+ Scenario read-model seeding/absence, synchronous handler exception propagation,
49
+ and the `EventContext.From` UTC default are verified separately at **Chronicle
50
+ 19.46.0**; they require **19.46.0+**. See the source paths in
51
+ [references/specifications.md](references/specifications.md).
52
+
48
53
  Reverify product sources before claiming support for another version.
49
54
 
50
55
  > **There is no `ReactorSideEffect` type.** It does not exist in Chronicle, in
@@ -363,7 +368,17 @@ when they serve that concern.
363
368
 
364
369
  Start with `Specification` plus a direct call for pure reactor decisions; add `ReactorScenario<TReactor>` only where invocation, dependency wiring or side effects contribute proof. Specify the contract, not only the happy path: field lineage, contract-authorized filtering, repeated delivery, and unrelated events.
365
370
 
366
- Read [references/specifications.md](references/specifications.md) when writing or reviewing reactor specifications.
371
+ In Chronicle **19.46.0+**, a handler's unseeded read-model parameter throws
372
+ `ReadModelNotSeededForReactorScenario`, even if nullable. Seed it through
373
+ `Given.ForEventSourceId(id).ReadModel(instance)`, or declare intentional absence
374
+ with `.NoReadModel<T>()`. Use the resolved read-model key; `Given` events do not
375
+ build read models, including passive ones. A synchronous handler throw surfaces
376
+ the handler's exception rather than a reflection wrapper, and the scenario's
377
+ `EventContext.Occurred` defaults to UTC.
378
+
379
+ Read [references/specifications.md](references/specifications.md) before writing
380
+ or reviewing reactor specifications; it shows both setup alternatives and the
381
+ scope of these scenario-only guards.
367
382
 
368
383
  ## Verify
369
384
 
@@ -1,3 +1,12 @@
1
+ # Reactor specifications
2
+
3
+ ## Contents
4
+
5
+ - Specifications
6
+ - Read-model setup — Chronicle 19.46.0+
7
+ - Exceptions and occurrence time — Chronicle 19.46.0+
8
+ - Contract coverage
9
+
1
10
  ## Specifications
2
11
 
3
12
  Start with `Specification` plus a direct call for pure reactor decisions,
@@ -9,6 +18,82 @@ non-event side effects; for handlers that return events, assert the resulting
9
18
  appends through the scenario's event store. Cover the replay path separately
10
19
  when the reactor has `[OnceOnly]` or `[Replay]` handlers.
11
20
 
21
+ ## Read-model setup — Chronicle 19.46.0+
22
+
23
+ Class-based `ReactorScenario<TReactor>` does not build read models from `Given`
24
+ events, **including passive models**. Seed a pre-built instance in `Establish()`:
25
+
26
+ ```csharp
27
+ void Establish() => _scenario.Given.ForEventSourceId(_id).ReadModel(_order);
28
+ async Task Because() => await _scenario.Given.ForEventSource(_id).Events(_orderPlaced);
29
+ ```
30
+
31
+ For a **separate absence specification**, use this setup instead, so an `Order?`
32
+ handler parameter deliberately receives `null`:
33
+
34
+ ```csharp
35
+ void Establish() => _scenario.Given.ForEventSourceId(_id).NoReadModel<Order>();
36
+ ```
37
+
38
+ The builders live in `Cratis.Chronicle.Testing.Reactors`. Exact signatures on
39
+ `ReactorSourceGivenBuilder<TReactor>` (`where TReactor : class, IReactor`):
40
+
41
+ ```csharp
42
+ public ReactorSourceGivenBuilder<TReactor> ReadModel<TReadModel>(TReadModel readModel)
43
+ where TReadModel : class;
44
+ public ReactorSourceGivenBuilder<TReactor> NoReadModel<TReadModel>()
45
+ where TReadModel : class;
46
+ public Task Events(params object[] events);
47
+ ```
48
+
49
+ Both `Given.ForEventSource(EventSourceId)` and
50
+ `Given.ForEventSourceId(EventSourceId)` return this builder. Seed or declare
51
+ absence using the triggering source id by default, or the key returned by
52
+ `ICanResolveReadModelKey.Resolve` when implemented. Declarations are shared
53
+ across the scenario's builders, match the **exact type and key**, and do not
54
+ remove a seeded instance. Do not declare absence to hide forgotten setup.
55
+
56
+ If a recognized read-model parameter resolves to `null` without declared
57
+ absence, `Events(...)` throws `ReadModelNotSeededForReactorScenario` from
58
+ `Cratis.Chronicle.Testing.Reactors`, **even for nullable parameters**. Its message
59
+ names the full read-model type and key, tells you to seed via `ReadModel(...)`
60
+ or declare `NoReadModel<T>()`, and lists other seeded keys for that type when
61
+ any exist. Check the key first if the fixture already seeds that type. This
62
+ guard applies only when the scenario uses `EventStoreForTesting` (including an
63
+ explicit instance); an explicitly supplied store of another type and production
64
+ read-model resolution are unchanged.
65
+
66
+ ## Exceptions and occurrence time — Chronicle 19.46.0+
67
+
68
+ `Events(...)` rethrows a synchronously throwing handler's **own exception**,
69
+ unwrapping the `TargetInvocationException` previously surfaced by reflection,
70
+ and preserves the handler stack. Capture with
71
+ `await Catch.Exception(() => _scenario.Given.ForEventSource(_id).Events(_orderPlaced))`
72
+ and assert the handler's exception type, not a reflection wrapper.
73
+
74
+ `EventContext.From(...)` defaults omitted `occurred` to `DateTimeOffset.UtcNow`
75
+ (previously `DateTimeOffset.Now`). `ReactorScenario` uses this helper, as do
76
+ `EventContext.Empty` and `EmptyWithEventSourceId(...)`. Explicit timestamps keep
77
+ their supplied offset; the positional constructor does not default `Occurred`.
78
+ For deterministic time-dependent plain-call specs, supply a fixed occurrence
79
+ time rather than asserting an exact current instant.
80
+
81
+ Verified against **Chronicle tag `v19.46.0`**:
82
+
83
+ - `Source/Clients/Testing/Reactors/ReactorSourceGivenBuilder.cs` and
84
+ `ReactorScenarioGivenBuilder.cs`: seeding, absence and builder signatures.
85
+ - `Source/Clients/Testing/Reactors/ScenarioReactorMethodArgumentsResolver.cs` and
86
+ `ReadModelNotSeededForReactorScenario.cs`: guard scope, resolved key and message.
87
+ - `Source/Clients/Testing/Reactors/ReactorScenario.cs`: synchronous exception
88
+ unwrapping and context creation.
89
+ - `Source/Clients/DotNET/Events/EventContext.cs`: UTC default and explicit time.
90
+ - `Source/Clients/Testing.Specs/Reactors/for_ReactorScenario/when_resolving_read_model_dependencies/`:
91
+ seeded, absent, nullable, passive, custom-key and explicit-store examples.
92
+ - `Source/Clients/Testing.Specs/Reactors/for_ReactorScenario/when_a_reactor_handler_throws_synchronously.cs`
93
+ and `when_a_reactor_reads_the_occurrence_time.cs`: handler exception and UTC specs.
94
+
95
+ ## Contract coverage
96
+
12
97
  Specify the contract, not only the happy path:
13
98
 
14
99
  - **Field lineage.** For each returned command or event, a specification asserts
@@ -31,6 +31,17 @@ Check the actual executable before relying on this table: `screenplay --version`
31
31
  `cratis --version`, MCP `initialize` → `serverInfo.version`. An older executable
32
32
  on PATH can shadow a newer install; inspect `which -a cratis`.
33
33
 
34
+ **Minimum for no-model MCP startup: Cratis CLI 3.46.1.** At `v3.46.1`,
35
+ `cratis screenplay mcp --project-root <project>` resolves a root without creating
36
+ it; startup, discovery and proposals leave no model folder, and the first
37
+ `apply` write creates it. `cratis ai install` creates no model folder unless a
38
+ root is configured. Older CLIs create the resolved root on server startup (`Screenplay/` when no `.play` files, `Source/` or `src/` exist). Released
39
+ source evidence: CLI
40
+ `Source/Cli.Specs/for_ScreenplayMcpInvocation/when_serving_a_project_without_a_model/and_the_client_writes_a_document.cs`
41
+ and `Source/Cli.Specs/for_AiMcpRegistration/when_installing/with_a_composed_profile.cs`
42
+ at tag `v3.46.1` (CLI#322 / CLI#311). This minimum does not re-label the compiler
43
+ or renderer probes below as results from the newer bundle.
44
+
34
45
  ## Two compilers, one rule
35
46
 
36
47
  Standalone Screenplay 4.131.0 and the CLI's 4.130.0 bundle are different releases.
@@ -22,6 +22,11 @@ This skill is verified against these exact public releases:
22
22
  | `Cratis.Arc.Testing` | `22.16.0` | `CommandScenario<TCommand>` and `CommandResult` assertions |
23
23
  | `Cratis.Chronicle.Testing` | `18.3.0` | `EventScenario`, `ReadModelScenario<T>`, `ReactorScenario<T>` |
24
24
 
25
+ Reactor read-model seeding/absence, synchronous handler exceptions and the
26
+ `EventContext.From` UTC default are verified separately at **Chronicle 19.46.0**
27
+ and require **19.46.0+**; see the ReactorScenario section in
28
+ [application-scenarios.md](references/application-scenarios.md).
29
+
25
30
  Generated-value scenario pinning is verified separately against
26
31
  `Cratis.Arc.Testing` **22.68.0** (introduced in **22.67.0**).
27
32
 
@@ -83,6 +88,14 @@ never produces a concurrency violation. `ShouldHaveConcurrencyViolations()`
83
88
  cannot pass for such an append and its negation passes vacuously — specify concurrency against the
84
89
  real kernel with an out-of-process integration specification instead.
85
90
 
91
+ For a class-based `ReactorScenario<TReactor>` on **Chronicle 19.46.0+**, seed each
92
+ handler read-model parameter with `Given.ForEventSourceId(id).ReadModel(instance)`
93
+ or declare intentional absence with `.NoReadModel<T>()`; `Given` events do not
94
+ build it, even if passive. Missing setup throws
95
+ `ReadModelNotSeededForReactorScenario` even for a nullable parameter. Use the
96
+ resolved read-model key. See the ReactorScenario reference for diagnostics,
97
+ exception capture and UTC occurrence-time defaults.
98
+
86
99
  Specification projects are named `<Source>.Specs` and run on xUnit.
87
100
 
88
101
  ### Pin server-generated Handle values
@@ -262,6 +262,47 @@ async Task Because() =>
262
262
  Construct with an `IServiceProvider` of substituted collaborators and assert on
263
263
  those substitutes after `Given` fires the events.
264
264
 
265
+ ### Read-model parameters — Chronicle 19.46.0+
266
+
267
+ `Given` events invoke the reactor; they **do not build read models**, including
268
+ passive ones. In the present-state specification, seed a pre-built instance:
269
+
270
+ ```csharp
271
+ void Establish() => _scenario.Given.ForEventSourceId(_id).ReadModel(_author);
272
+ ```
273
+
274
+ In a **separate absence specification**, deliberately let an `Author?` handler
275
+ parameter receive `null`:
276
+
277
+ ```csharp
278
+ void Establish() => _scenario.Given.ForEventSourceId(_id).NoReadModel<Author>();
279
+ ```
280
+
281
+ Both return `ReactorSourceGivenBuilder<TReactor>` for chaining, not a `Task`.
282
+ Use the triggering source id by default or `ICanResolveReadModelKey.Resolve`'s
283
+ key when implemented. Absence matches the exact type and key and does not remove
284
+ a seeded instance. Missing setup throws `ReadModelNotSeededForReactorScenario`
285
+ (`Cratis.Chronicle.Testing.Reactors`), **even for nullable parameters**. Its
286
+ message names the type/key, explains both setups, and lists other seeded keys
287
+ for that type when any exist. Fix the fixture/key rather than hiding missing
288
+ setup with `NoReadModel<T>()`. This guard applies to `EventStoreForTesting`, not
289
+ production resolution or an explicitly supplied store of another type.
290
+
291
+ Also in **Chronicle 19.46.0+**, a synchronous handler throw surfaces its own
292
+ exception, not reflection's `TargetInvocationException`, preserving the handler
293
+ stack. Capture with `await Catch.Exception(() => _scenario.Given.ForEventSource(_id).Events(...))`
294
+ and assert the handler's exception type.
295
+
296
+ `EventContext.From(...)` defaults omitted `occurred` to `DateTimeOffset.UtcNow`,
297
+ previously local `Now`. Scenario contexts, `EventContext.Empty` and
298
+ `EmptyWithEventSourceId(...)` use this helper; explicit times retain their offset,
299
+ and the positional constructor has no such default.
300
+
301
+ Sources at **Chronicle tag `v19.46.0`**:
302
+ `Source/Clients/Testing/Reactors/{ReactorScenario,ReactorSourceGivenBuilder,ScenarioReactorMethodArgumentsResolver,ReadModelNotSeededForReactorScenario}.cs`
303
+ and `Source/Clients/DotNET/Events/EventContext.cs`. For exact signatures and
304
+ source-backed examples, read `cratis-chronicle-reactor/references/specifications.md`.
305
+
265
306
  ## Application conventions
266
307
 
267
308
  - **Use a per-specification fixture value for anything under a shared uniqueness
@@ -114,6 +114,7 @@
114
114
  "cratis/arc/core",
115
115
  "cratis/chronicle/core",
116
116
  "cratis/fundamentals",
117
+ "cratis/screenplay",
117
118
  "cratis/specifications/dotnet"
118
119
  ],
119
120
  "languages": [
@@ -125,6 +126,7 @@
125
126
  "composes": [
126
127
  "cratis/arc/core",
127
128
  "cratis/fundamentals",
129
+ "cratis/screenplay",
128
130
  "cratis/specifications/dotnet"
129
131
  ],
130
132
  "languages": [
@@ -137,6 +139,7 @@
137
139
  "cratis/chronicle/client-dotnet",
138
140
  "cratis/chronicle/core",
139
141
  "cratis/fundamentals",
142
+ "cratis/screenplay",
140
143
  "cratis/specifications/dotnet"
141
144
  ],
142
145
  "languages": [
@@ -152,6 +155,7 @@
152
155
  "cratis/components",
153
156
  "cratis/fundamentals",
154
157
  "cratis/language/csharp",
158
+ "cratis/screenplay",
155
159
  "cratis/specifications/dotnet",
156
160
  "cratis/specifications/typescript"
157
161
  ],
@@ -165,7 +169,8 @@
165
169
  "id": "cratis/application/elixir",
166
170
  "composes": [
167
171
  "cratis/chronicle/client-elixir",
168
- "cratis/language/elixir"
172
+ "cratis/language/elixir",
173
+ "cratis/screenplay"
169
174
  ],
170
175
  "languages": [
171
176
  "elixir"
@@ -176,7 +181,8 @@
176
181
  "composes": [
177
182
  "cratis/arc/client-kotlin",
178
183
  "cratis/chronicle/client-java",
179
- "cratis/language/java"
184
+ "cratis/language/java",
185
+ "cratis/screenplay"
180
186
  ],
181
187
  "languages": [
182
188
  "java"
@@ -187,7 +193,8 @@
187
193
  "composes": [
188
194
  "cratis/arc/client-kotlin",
189
195
  "cratis/chronicle/client-kotlin",
190
- "cratis/language/kotlin"
196
+ "cratis/language/kotlin",
197
+ "cratis/screenplay"
191
198
  ],
192
199
  "languages": [
193
200
  "kotlin"
@@ -214,6 +221,7 @@
214
221
  "composes": [
215
222
  "cratis/chronicle/client-typescript",
216
223
  "cratis/language/typescript",
224
+ "cratis/screenplay",
217
225
  "cratis/specifications/typescript"
218
226
  ],
219
227
  "languages": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/pi",
3
- "version": "7.28.0",
3
+ "version": "7.29.0",
4
4
  "description": "Configuration-aware Cratis AI integration for Pi",
5
5
  "type": "module",
6
6
  "files": [