@cratis/pi 7.94.0 → 7.96.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 +14 -0
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/CorpusServer.ts +16 -0
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/ServerBridge.ts +113 -0
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/StdioConnection.ts +29 -20
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/capabilities.json +1 -0
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/configuration.ts +26 -5
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/index.ts +14 -5
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/process.ts +12 -0
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/serverProtocol.ts +92 -0
- package/package/corpus/harnesses/pi/extensions/cratis-mcp/servers.ts +76 -0
- package/package/corpus/skills/cratis-chronicle-cli-operations/SKILL.md +4 -0
- package/package/corpus/skills/cratis-screenplay-render-and-gap-fill/SKILL.md +1 -0
- package/package/corpus/skills/cratis-screenplay-render-and-gap-fill/references/post-deploy-check.md +57 -0
- package/package/corpus/skills/cratis-screenplay-toolchain/SKILL.md +3 -1
- package/package/corpus/skills/cratis-screenplay-toolchain/references/observation-comparison.md +83 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,6 +33,20 @@ yields to the managed installation to avoid duplicate resources and hooks. The P
|
|
|
33
33
|
package manages only Pi; it neither configures other harnesses nor owns a local
|
|
34
34
|
managed corpus.
|
|
35
35
|
|
|
36
|
+
## MCP servers from the corpus
|
|
37
|
+
|
|
38
|
+
The packaged `cratis-mcp` extension starts every stdio server declared in
|
|
39
|
+
`mcp-servers.json` whose `profiles` intersect the profiles listed explicitly in
|
|
40
|
+
`.cratis/ai.json` (a profile reached only through another profile's `composes` does
|
|
41
|
+
not start a server; Screenplay keeps its composed selection) (for
|
|
42
|
+
example `codescene` for `cratis/codescene`, `playwright` for `cratis/playwright`).
|
|
43
|
+
Each server gets its own connection, so one failing to start never stops the others,
|
|
44
|
+
and its tools are namespaced by server id (`playwright_browser_navigate`). The
|
|
45
|
+
`command`, `args` and optional literal `env` are used verbatim, without a shell. A
|
|
46
|
+
malformed selected server is skipped with one notice; set
|
|
47
|
+
`mcpServers.<id>.enabled` to `false` in `.cratis/ai.json` to opt a server out.
|
|
48
|
+
`capabilities.json` beside the extension declares this behavior to the Cratis CLI.
|
|
49
|
+
|
|
36
50
|
## Screenplay MCP tools
|
|
37
51
|
|
|
38
52
|
The packaged `cratis-mcp` extension starts the Screenplay MCP server
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
/** A validated stdio server from the corpus descriptor; command, args and env are used verbatim. */
|
|
5
|
+
export interface CorpusServer {
|
|
6
|
+
id: string;
|
|
7
|
+
command: string;
|
|
8
|
+
args: string[];
|
|
9
|
+
env: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The servers to start for a project plus the explicit messages for everything skipped. */
|
|
13
|
+
export interface ServerSelection {
|
|
14
|
+
servers: CorpusServer[];
|
|
15
|
+
messages: string[];
|
|
16
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { object } from './configuration.ts';
|
|
6
|
+
import type { CorpusServer } from './CorpusServer.ts';
|
|
7
|
+
import { startServer } from './process.ts';
|
|
8
|
+
import { discoverServer, mapServerResult, type ServerCatalog } from './serverProtocol.ts';
|
|
9
|
+
import { selectedCorpusServers } from './servers.ts';
|
|
10
|
+
import type { StdioConnection } from './StdioConnection.ts';
|
|
11
|
+
|
|
12
|
+
function withDeadline<T>(work: Promise<T>, milliseconds: number, id: string): Promise<T> {
|
|
13
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
14
|
+
const expired = new Promise<never>((_resolve, reject) => {
|
|
15
|
+
timer = setTimeout(() => reject(new Error(`MCP server '${id}' did not become ready within ${milliseconds} ms.`)), milliseconds);
|
|
16
|
+
timer.unref();
|
|
17
|
+
});
|
|
18
|
+
work.catch(() => { /* The deadline already reported the outcome; the disposed connection rejects the abandoned request. */ });
|
|
19
|
+
return Promise.race([work, expired]).finally(() => clearTimeout(timer));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Bridge every selected corpus server except Screenplay: one connection each, isolated failures, tools namespaced by server id. */
|
|
23
|
+
export interface ServerBridge {
|
|
24
|
+
refresh(context: ExtensionContext, newSession: boolean): Promise<void>;
|
|
25
|
+
dispose(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createServerBridge(pi: ExtensionAPI, corpus: string, connect: (project: string, server: CorpusServer) => StdioConnection = startServer, deadlineMilliseconds = 15_000): ServerBridge {
|
|
29
|
+
const connections = new Map<string, StdioConnection>();
|
|
30
|
+
const registered = new Set<string>();
|
|
31
|
+
const paused = new Set<string>();
|
|
32
|
+
let selectionKey: string | undefined;
|
|
33
|
+
|
|
34
|
+
const notify = (context: ExtensionContext, messages: string[]): void => {
|
|
35
|
+
if (context.hasUI) messages.forEach(message => context.ui.notify(message, 'warning'));
|
|
36
|
+
};
|
|
37
|
+
const deactivate = (): void => {
|
|
38
|
+
const active = pi.getActiveTools();
|
|
39
|
+
active.filter(name => registered.has(name)).forEach(name => paused.add(name));
|
|
40
|
+
if (active.some(name => registered.has(name))) pi.setActiveTools(active.filter(name => !registered.has(name)));
|
|
41
|
+
};
|
|
42
|
+
const dispose = (): void => {
|
|
43
|
+
connections.forEach(connection => connection.dispose());
|
|
44
|
+
connections.clear();
|
|
45
|
+
deactivate();
|
|
46
|
+
};
|
|
47
|
+
interface Started { server: CorpusServer; connection: StdioConnection; catalog?: ServerCatalog; messages: string[] }
|
|
48
|
+
/** Connect and discover only; registration happens afterwards in selection order so name claims are deterministic. */
|
|
49
|
+
const start = async (context: ExtensionContext, server: CorpusServer): Promise<Started> => {
|
|
50
|
+
const connection = connect(context.cwd, server);
|
|
51
|
+
connections.set(server.id, connection);
|
|
52
|
+
try {
|
|
53
|
+
const catalog = await withDeadline(discoverServer(connection, server.id), deadlineMilliseconds, server.id);
|
|
54
|
+
if (connections.get(server.id) !== connection || connection.stopped) throw new Error(`MCP server '${server.id}' session ended during discovery.`);
|
|
55
|
+
return { server, connection, catalog, messages: [...catalog.messages] };
|
|
56
|
+
} catch (error) {
|
|
57
|
+
connection.dispose();
|
|
58
|
+
if (connections.get(server.id) === connection) connections.delete(server.id);
|
|
59
|
+
return { server, connection, messages: [`MCP server '${server.id}' is unavailable: ${error instanceof Error ? error.message : String(error)}`] };
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const register = ({ server, connection, catalog, messages }: Started, claimed: Set<string>): void => {
|
|
63
|
+
const existing = new Set(pi.getAllTools().map(tool => tool.name));
|
|
64
|
+
for (const tool of catalog?.tools ?? []) {
|
|
65
|
+
if (claimed.has(tool.nativeName) || (existing.has(tool.nativeName) && !registered.has(tool.nativeName))) {
|
|
66
|
+
messages.push(`MCP server '${server.id}' tool '${tool.name}' was skipped: '${tool.nativeName}' conflicts with another tool.`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
claimed.add(tool.nativeName);
|
|
70
|
+
pi.registerTool({
|
|
71
|
+
name: tool.nativeName,
|
|
72
|
+
label: `${server.id} ${tool.name}`,
|
|
73
|
+
description: tool.description,
|
|
74
|
+
parameters: tool.parameters,
|
|
75
|
+
executionMode: 'sequential',
|
|
76
|
+
async execute(_toolCallId, parameters, signal) {
|
|
77
|
+
if (connections.get(server.id) !== connection || connection.stopped || !pi.getActiveTools().includes(tool.nativeName)) {
|
|
78
|
+
throw new Error(`MCP server '${server.id}' is unavailable in this session.`);
|
|
79
|
+
}
|
|
80
|
+
if (!object(parameters)) throw new Error('MCP arguments must be an object.');
|
|
81
|
+
const result = mapServerResult(await connection.request('tools/call', { name: tool.name, arguments: parameters }, signal), server.id);
|
|
82
|
+
if (result.details.isError) throw new Error(result.content.map(part => part.type === 'text' ? part.text : '').join('\n') || `MCP server '${server.id}' reported a failed tool call.`);
|
|
83
|
+
return result;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const refresh = async (context: ExtensionContext, newSession: boolean): Promise<void> => {
|
|
89
|
+
let selection: ReturnType<typeof selectedCorpusServers>;
|
|
90
|
+
try { selection = selectedCorpusServers(context.cwd, corpus); }
|
|
91
|
+
catch (error) { dispose(); selectionKey = undefined; throw error; }
|
|
92
|
+
const key = JSON.stringify(selection);
|
|
93
|
+
if (!newSession && key === selectionKey) return;
|
|
94
|
+
const previouslyActive = new Set([...pi.getActiveTools(), ...paused]);
|
|
95
|
+
const oldNames = new Set(registered);
|
|
96
|
+
dispose();
|
|
97
|
+
selectionKey = key;
|
|
98
|
+
const messages = [...selection.messages];
|
|
99
|
+
const claimed = new Set<string>();
|
|
100
|
+
// Servers start in parallel; a slow or hung one is abandoned at its own deadline and never delays the others.
|
|
101
|
+
const outcomes = await Promise.all(selection.servers.map(server => start(context, server)));
|
|
102
|
+
for (const outcome of outcomes) { register(outcome, claimed); messages.push(...outcome.messages); }
|
|
103
|
+
claimed.forEach(name => registered.add(name));
|
|
104
|
+
// Never re-enable a user-disabled tool. SDK allow/exclude restrictions still filter registration.
|
|
105
|
+
const next = pi.getActiveTools().filter(name => !registered.has(name) || (claimed.has(name) && (!oldNames.has(name) || previouslyActive.has(name))));
|
|
106
|
+
for (const name of claimed) if (oldNames.has(name) && previouslyActive.has(name)) next.push(name);
|
|
107
|
+
pi.setActiveTools([...new Set(next)]);
|
|
108
|
+
paused.clear();
|
|
109
|
+
notify(context, messages);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return { refresh, dispose: () => { dispose(); selectionKey = undefined; } };
|
|
113
|
+
}
|
|
@@ -15,7 +15,7 @@ export class StdioConnection {
|
|
|
15
15
|
private _termination?: ReturnType<typeof setTimeout>;
|
|
16
16
|
private readonly _maximumBytes = 8 * 1024 * 1024;
|
|
17
17
|
|
|
18
|
-
constructor(private readonly _child: ChildProcessWithoutNullStreams, private readonly _timeout = 30_000) {
|
|
18
|
+
constructor(private readonly _child: ChildProcessWithoutNullStreams, private readonly _timeout = 30_000, private readonly _label = 'Screenplay', private readonly _tolerant = false) {
|
|
19
19
|
_child.stdout.on('data', this.receive);
|
|
20
20
|
_child.stdout.on('end', this.ended);
|
|
21
21
|
_child.stdout.on('error', this.streamFailed);
|
|
@@ -32,22 +32,22 @@ export class StdioConnection {
|
|
|
32
32
|
|
|
33
33
|
async request(method: string, params: Record<string, unknown>, signal?: AbortSignal, mutation = false): Promise<unknown> {
|
|
34
34
|
if (this._failure) throw this._failure;
|
|
35
|
-
if (signal?.aborted) throw new ConnectionFailure(
|
|
36
|
-
if (this._pending) throw new ConnectionFailure(
|
|
35
|
+
if (signal?.aborted) throw new ConnectionFailure(`${this._label} request cancelled before sending.`);
|
|
36
|
+
if (this._pending) throw new ConnectionFailure(`${this._label} is busy; parallel requests are not queued.`);
|
|
37
37
|
const id = ++this._sequence;
|
|
38
38
|
const line = this.encode({ jsonrpc: '2.0', id, method, params });
|
|
39
39
|
return new Promise((resolve, reject) => {
|
|
40
|
-
const abort = () => this.stop(
|
|
41
|
-
const timer = setTimeout(() => this.stop(
|
|
40
|
+
const abort = () => this.stop(`${this._label} request cancelled after sending.`);
|
|
41
|
+
const timer = setTimeout(() => this.stop(`${this._label} request timed out.`), this._timeout);
|
|
42
42
|
this._pending = {
|
|
43
43
|
id, mutation, resolve, reject,
|
|
44
44
|
cleanup: () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); },
|
|
45
45
|
};
|
|
46
46
|
signal?.addEventListener('abort', abort, { once: true });
|
|
47
47
|
try {
|
|
48
|
-
this._child.stdin.write(line, error => { if (error) this.stop(
|
|
48
|
+
this._child.stdin.write(line, error => { if (error) this.stop(`${this._label} input pipe failed.`); });
|
|
49
49
|
} catch {
|
|
50
|
-
this.stop(
|
|
50
|
+
this.stop(`${this._label} input pipe failed.`);
|
|
51
51
|
}
|
|
52
52
|
});
|
|
53
53
|
}
|
|
@@ -55,18 +55,18 @@ export class StdioConnection {
|
|
|
55
55
|
notify(method: string): void {
|
|
56
56
|
if (this._failure) throw this._failure;
|
|
57
57
|
try {
|
|
58
|
-
this._child.stdin.write(this.encode({ jsonrpc: '2.0', method }), error => { if (error) this.stop(
|
|
58
|
+
this._child.stdin.write(this.encode({ jsonrpc: '2.0', method }), error => { if (error) this.stop(`${this._label} notification failed.`); });
|
|
59
59
|
} catch {
|
|
60
|
-
this.stop(
|
|
60
|
+
this.stop(`${this._label} notification failed.`);
|
|
61
61
|
throw this._failure;
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
dispose(): void { this.stop(
|
|
65
|
+
dispose(): void { this.stop(`${this._label} session closed.`); }
|
|
66
66
|
|
|
67
67
|
private encode(value: unknown): string {
|
|
68
68
|
const line = `${JSON.stringify(value)}\n`;
|
|
69
|
-
if (Buffer.byteLength(line) > this._maximumBytes) throw new ConnectionFailure(
|
|
69
|
+
if (Buffer.byteLength(line) > this._maximumBytes) throw new ConnectionFailure(`${this._label} request exceeds the 8 MiB transport limit.`);
|
|
70
70
|
return line;
|
|
71
71
|
}
|
|
72
72
|
|
|
@@ -82,7 +82,7 @@ export class StdioConnection {
|
|
|
82
82
|
this._buffer = Buffer.alloc(0);
|
|
83
83
|
this.accept(line);
|
|
84
84
|
} catch {
|
|
85
|
-
this.stop(
|
|
85
|
+
this.stop(`${this._label} returned invalid UTF-8 protocol data.`);
|
|
86
86
|
}
|
|
87
87
|
if (this.stopped) return;
|
|
88
88
|
start = index + 1;
|
|
@@ -92,7 +92,7 @@ export class StdioConnection {
|
|
|
92
92
|
|
|
93
93
|
private append(chunk: Buffer): boolean {
|
|
94
94
|
if (this._buffer.length + chunk.length > this._maximumBytes) {
|
|
95
|
-
this.stop(
|
|
95
|
+
this.stop(`${this._label} response exceeds the 8 MiB transport limit.`);
|
|
96
96
|
return false;
|
|
97
97
|
}
|
|
98
98
|
this._buffer = Buffer.concat([this._buffer, chunk]);
|
|
@@ -103,6 +103,13 @@ export class StdioConnection {
|
|
|
103
103
|
try {
|
|
104
104
|
const message: unknown = JSON.parse(line);
|
|
105
105
|
if (!object(message) || message.jsonrpc !== '2.0') throw new Error();
|
|
106
|
+
if ('method' in message && this._tolerant) {
|
|
107
|
+
// Third-party servers may notify or ask; this client offers no capabilities, so refuse requests and ignore notifications.
|
|
108
|
+
if ('id' in message && typeof message.method === 'string') {
|
|
109
|
+
this._child.stdin.write(this.encode({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Method not found' } }));
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
106
113
|
if ('method' in message) {
|
|
107
114
|
// This pinned server does not send requests or changing tool lists.
|
|
108
115
|
throw new Error();
|
|
@@ -113,14 +120,14 @@ export class StdioConnection {
|
|
|
113
120
|
this._pending = undefined;
|
|
114
121
|
pending.cleanup();
|
|
115
122
|
if (object(message.error)) {
|
|
116
|
-
pending.reject(new ConnectionFailure(
|
|
123
|
+
pending.reject(new ConnectionFailure(`${this._label} protocol error ${message.error.code}: ${message.error.message}`, pending.mutation));
|
|
117
124
|
// A server failure during mutation is not evidence that nothing was written.
|
|
118
|
-
if (pending.mutation) this.stop(
|
|
125
|
+
if (pending.mutation) this.stop(`${this._label} mutation failed with a protocol error.`, true);
|
|
119
126
|
} else {
|
|
120
127
|
pending.resolve(message.result);
|
|
121
128
|
}
|
|
122
129
|
} catch {
|
|
123
|
-
this.stop(
|
|
130
|
+
this.stop(`${this._label} returned malformed or unexpected JSON-RPC.`);
|
|
124
131
|
}
|
|
125
132
|
}
|
|
126
133
|
|
|
@@ -145,11 +152,13 @@ export class StdioConnection {
|
|
|
145
152
|
}
|
|
146
153
|
|
|
147
154
|
private readonly drain = (): void => { /* Drain server stderr without exposing it as a protocol result. */ };
|
|
148
|
-
private readonly ended = (): void => this.stop(
|
|
149
|
-
private readonly streamFailed = (): void => this.stop(
|
|
150
|
-
private readonly spawnFailed = (): void => this.stop(
|
|
155
|
+
private readonly ended = (): void => this.stop(`${this._label} output closed before session shutdown.`);
|
|
156
|
+
private readonly streamFailed = (): void => this.stop(`${this._label} stdio failed.`);
|
|
157
|
+
private readonly spawnFailed = (): void => this.stop(this._label !== 'Screenplay'
|
|
158
|
+
? `Cannot launch the ${this._label} MCP server command. Check that it is installed; this extension never downloads a runtime.`
|
|
159
|
+
: 'Cannot launch native cratis screenplay mcp. Install/update the Cratis CLI through its normal channel; this extension never downloads a runtime.');
|
|
151
160
|
private readonly closed = (): void => {
|
|
152
|
-
this.stop('Screenplay process exited. Check that the installed Cratis CLI includes the embedded Screenplay runtime; no runtime is downloaded by this extension.');
|
|
161
|
+
this.stop(this._label !== 'Screenplay' ? `${this._label} process exited.` : 'Screenplay process exited. Check that the installed Cratis CLI includes the embedded Screenplay runtime; no runtime is downloaded by this extension.');
|
|
153
162
|
clearTimeout(this._termination);
|
|
154
163
|
this._child.stdout.removeListener('error', this.streamFailed);
|
|
155
164
|
this._child.stdin.removeListener('error', this.streamFailed);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "servers": "selected-profiles" }
|
|
@@ -8,7 +8,7 @@ export function object(value: unknown): value is Record<string, unknown> {
|
|
|
8
8
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
function readJson(path: string): Record<string, unknown> {
|
|
11
|
+
export function readJson(path: string): Record<string, unknown> {
|
|
12
12
|
if (lstatSync(path).size > 1024 * 1024) throw new Error(`Cratis configuration is too large: ${path}`);
|
|
13
13
|
const value: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
14
14
|
if (!object(value)) throw new Error(`Expected a JSON object: ${path}`);
|
|
@@ -21,6 +21,10 @@ function strings(value: unknown): string[] {
|
|
|
21
21
|
return value;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export function supportedSchema(version: unknown): boolean {
|
|
25
|
+
return typeof version === 'string' && /^1\.\d+$/.test(version);
|
|
26
|
+
}
|
|
27
|
+
|
|
24
28
|
/** Resolve through existing directories only, refusing every source-root symlink. Never creates a directory. */
|
|
25
29
|
export function physicalRoot(project: string, root: string): string {
|
|
26
30
|
if (!root || isAbsolute(root) || root.includes('\0')) throw new Error('Screenplay root must be a nonempty project-relative directory.');
|
|
@@ -45,8 +49,16 @@ export function physicalRoot(project: string, root: string): string {
|
|
|
45
49
|
return current;
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
export interface ProfileResolution {
|
|
53
|
+
configuration: Record<string, unknown>;
|
|
54
|
+
selected: Set<string>;
|
|
55
|
+
/** Profiles listed directly in `.cratis/ai.json`, before any `composes` expansion. */
|
|
56
|
+
explicit: Set<string>;
|
|
57
|
+
nonApplicationSelection: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Resolve the selected profiles (after `composes` expansion) from the project's `.cratis/ai.json`. */
|
|
61
|
+
export function resolveProfiles(project: string, corpus: string): ProfileResolution | undefined {
|
|
50
62
|
let configuration: Record<string, unknown>;
|
|
51
63
|
try {
|
|
52
64
|
configuration = readJson(join(project, '.cratis', 'ai.json'));
|
|
@@ -86,7 +98,14 @@ export function selectedServer(project: string, corpus: string) {
|
|
|
86
98
|
strings(profile.composes).forEach(child => select(child, false, applicationPath));
|
|
87
99
|
};
|
|
88
100
|
requested.forEach(id => select(id, true));
|
|
89
|
-
|
|
101
|
+
return { configuration, selected, explicit: new Set(requested), nonApplicationSelection };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Select only the shipped Screenplay descriptor, never project-supplied commands or environments. */
|
|
105
|
+
export function selectedServer(project: string, corpus: string) {
|
|
106
|
+
const resolution = resolveProfiles(project, corpus);
|
|
107
|
+
if (!resolution?.selected.has('cratis/screenplay')) return undefined;
|
|
108
|
+
const { configuration, nonApplicationSelection } = resolution;
|
|
90
109
|
const overrides = configuration.mcpServers;
|
|
91
110
|
if (overrides !== undefined && !object(overrides)) throw new Error('Invalid mcpServers configuration.');
|
|
92
111
|
const screenplay = object(overrides) ? overrides.screenplay : undefined;
|
|
@@ -94,7 +113,9 @@ export function selectedServer(project: string, corpus: string) {
|
|
|
94
113
|
if (object(screenplay) && screenplay.enabled === false) return undefined;
|
|
95
114
|
if (object(screenplay) && screenplay.enabled !== undefined && screenplay.enabled !== true) throw new Error('Screenplay MCP enabled must be a boolean.');
|
|
96
115
|
const descriptor = readJson(join(corpus, 'mcp-servers.json'));
|
|
97
|
-
|
|
116
|
+
// Any 1.x descriptor is accepted; another major loads no corpus MCP servers (the generic bridge reports it once).
|
|
117
|
+
if (!supportedSchema(descriptor.schemaVersion)) return undefined;
|
|
118
|
+
if (!Array.isArray(descriptor.servers)) throw new Error('Unsupported Cratis MCP descriptor.');
|
|
98
119
|
const servers = descriptor.servers.filter(server => object(server) && server.id === 'screenplay');
|
|
99
120
|
if (servers.length !== 1 || !object(servers[0])) throw new Error('Expected exactly one Screenplay MCP descriptor.');
|
|
100
121
|
const server = servers[0];
|
|
@@ -7,8 +7,10 @@ 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 {
|
|
10
|
+
import type { CorpusServer } from './CorpusServer.ts';
|
|
11
|
+
import { startConnection, startServer, supportsNoModelStartup } from './process.ts';
|
|
11
12
|
import { discover, failedResult, mapResult } from './protocol.ts';
|
|
13
|
+
import { createServerBridge } from './ServerBridge.ts';
|
|
12
14
|
import type { DiscoveredTool } from './DiscoveredTool.ts';
|
|
13
15
|
import type { StdioConnection } from './StdioConnection.ts';
|
|
14
16
|
|
|
@@ -16,7 +18,8 @@ const extensionDirectory = dirname(fileURLToPath(import.meta.url));
|
|
|
16
18
|
const bundledCorpus = resolve(extensionDirectory, '..', '..', '..', '..');
|
|
17
19
|
|
|
18
20
|
/** Native tools go through Pi's tool_call restrictions and normal execution/result pipeline. */
|
|
19
|
-
export function registerBridge(pi: ExtensionAPI, corpus: string, connect = startConnection, checkVersion = supportsNoModelStartup): void {
|
|
21
|
+
export function registerBridge(pi: ExtensionAPI, corpus: string, connect = startConnection, checkVersion = supportsNoModelStartup, connectServer: (project: string, server: CorpusServer) => StdioConnection = startServer): void {
|
|
22
|
+
const servers = createServerBridge(pi, corpus, connectServer);
|
|
20
23
|
let connection: StdioConnection | undefined;
|
|
21
24
|
let selectionKey: string | undefined;
|
|
22
25
|
let attempted = false;
|
|
@@ -149,9 +152,15 @@ export function registerBridge(pi: ExtensionAPI, corpus: string, connect = start
|
|
|
149
152
|
}
|
|
150
153
|
};
|
|
151
154
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
+
// Screenplay and the other corpus servers fail independently; the first failure is surfaced after both ran.
|
|
156
|
+
const refreshAll = async (context: ExtensionContext, newSession: boolean): Promise<void> => {
|
|
157
|
+
const outcomes = await Promise.allSettled([refresh(context, newSession), servers.refresh(context, newSession)]);
|
|
158
|
+
const failed = outcomes.find(outcome => outcome.status === 'rejected');
|
|
159
|
+
if (failed) throw failed.reason;
|
|
160
|
+
};
|
|
161
|
+
pi.on('session_start', (_event, context) => refreshAll(context, true));
|
|
162
|
+
pi.on('before_agent_start', async (_event, context) => { await refreshAll(context, false); });
|
|
163
|
+
pi.on('session_shutdown', () => { dispose(); servers.dispose(); failures.clear(); });
|
|
155
164
|
pi.on('tool_result', event => {
|
|
156
165
|
if (!registered.has(event.toolName)) return;
|
|
157
166
|
const failure = failures.get(event.toolCallId);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
3
|
|
|
4
4
|
import { execFile, spawn } from 'node:child_process';
|
|
5
|
+
import type { CorpusServer } from './CorpusServer.ts';
|
|
5
6
|
import { StdioConnection } from './StdioConnection.ts';
|
|
6
7
|
|
|
7
8
|
/** Preserve OS/runtime lookup only; never copy arbitrary credentials or descriptor environment values. */
|
|
@@ -43,3 +44,14 @@ export function startConnection(project: string, launch: typeof spawn = spawn):
|
|
|
43
44
|
windowsHide: true,
|
|
44
45
|
}));
|
|
45
46
|
}
|
|
47
|
+
|
|
48
|
+
/** Launch a corpus server verbatim (no shell, no interpolation): the default environment plus its declared literal env only. */
|
|
49
|
+
export function startServer(project: string, server: CorpusServer, launch: typeof spawn = spawn): StdioConnection {
|
|
50
|
+
return new StdioConnection(launch(server.command, server.args, {
|
|
51
|
+
cwd: project,
|
|
52
|
+
env: { ...childEnvironment(), ...server.env },
|
|
53
|
+
shell: false,
|
|
54
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
55
|
+
windowsHide: true,
|
|
56
|
+
}), 30_000, server.id, true);
|
|
57
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import type { TSchema } from 'typebox';
|
|
5
|
+
import { object } from './configuration.ts';
|
|
6
|
+
import type { StdioConnection } from './StdioConnection.ts';
|
|
7
|
+
|
|
8
|
+
/** A tool of a corpus server other than Screenplay, exposed under a name namespaced by the server id. */
|
|
9
|
+
export interface ServerTool {
|
|
10
|
+
name: string;
|
|
11
|
+
nativeName: string;
|
|
12
|
+
description: string;
|
|
13
|
+
parameters: TSchema;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ServerCatalog {
|
|
17
|
+
tools: ServerTool[];
|
|
18
|
+
messages: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const maximumTools = 128;
|
|
22
|
+
const maximumPages = 8;
|
|
23
|
+
const maximumNativeNameLength = 64;
|
|
24
|
+
const maximumDescriptionLength = 4 * 1024;
|
|
25
|
+
const maximumSchemaLength = 64 * 1024;
|
|
26
|
+
const maximumImageLength = 2 * 1024 * 1024;
|
|
27
|
+
const imageTypes = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
|
28
|
+
|
|
29
|
+
/** Initialize and list tools; tools that cannot be exposed safely are skipped with a message rather than failing the server. */
|
|
30
|
+
export async function discoverServer(connection: StdioConnection, id: string): Promise<ServerCatalog> {
|
|
31
|
+
const initialized = await connection.request('initialize', {
|
|
32
|
+
protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'cratis.pi', version: '1.0.0' },
|
|
33
|
+
});
|
|
34
|
+
if (!object(initialized) || !object(initialized.capabilities) || !object(initialized.capabilities.tools)) {
|
|
35
|
+
throw new Error(`MCP server '${id}' did not advertise tool support.`);
|
|
36
|
+
}
|
|
37
|
+
connection.notify('notifications/initialized');
|
|
38
|
+
const listed: unknown[] = [];
|
|
39
|
+
let cursor: unknown;
|
|
40
|
+
for (let page = 0; page < maximumPages; page++) {
|
|
41
|
+
const response = await connection.request('tools/list', cursor === undefined ? {} : { cursor });
|
|
42
|
+
if (!object(response) || !Array.isArray(response.tools)) throw new Error(`MCP server '${id}' returned an invalid tool list.`);
|
|
43
|
+
listed.push(...response.tools);
|
|
44
|
+
if (listed.length > maximumTools) throw new Error(`MCP server '${id}' exposes more than ${maximumTools} tools.`);
|
|
45
|
+
cursor = response.nextCursor;
|
|
46
|
+
if (cursor === undefined) break;
|
|
47
|
+
if (typeof cursor !== 'string') throw new Error(`MCP server '${id}' returned an invalid pagination cursor.`);
|
|
48
|
+
}
|
|
49
|
+
const catalog: ServerCatalog = { tools: [], messages: [] };
|
|
50
|
+
const names = new Set<string>();
|
|
51
|
+
for (const tool of listed) {
|
|
52
|
+
const name = object(tool) ? tool.name : undefined;
|
|
53
|
+
if (!object(tool) || typeof name !== 'string' || !name || !object(tool.inputSchema) || tool.inputSchema.type !== 'object' ||
|
|
54
|
+
(tool.description !== undefined && typeof tool.description !== 'string')) {
|
|
55
|
+
catalog.messages.push(`MCP server '${id}' has a tool with an invalid name or input schema; it was skipped.`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (JSON.stringify(tool.inputSchema).length > maximumSchemaLength || ((tool.description as string | undefined)?.length ?? 0) > maximumDescriptionLength) {
|
|
59
|
+
catalog.messages.push(`MCP server '${id}' tool '${name}' was skipped: its description or input schema is too large.`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const nativeName = `${id}_${name.replace(/[^A-Za-z0-9_]/g, '_')}`;
|
|
63
|
+
if (nativeName.length > maximumNativeNameLength || names.has(nativeName)) {
|
|
64
|
+
catalog.messages.push(`MCP server '${id}' tool '${name}' was skipped: its namespaced name is too long or not unique.`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
names.add(nativeName);
|
|
68
|
+
catalog.tools.push({ name, nativeName, description: (tool.description as string | undefined) ?? '', parameters: tool.inputSchema as TSchema });
|
|
69
|
+
}
|
|
70
|
+
if (!catalog.tools.length) throw new Error(`MCP server '${id}' exposes no usable tools.`);
|
|
71
|
+
return catalog;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Map an MCP result to Pi content. Server-reported errors throw so Pi marks the call failed. */
|
|
75
|
+
export function mapServerResult(result: unknown, id: string) {
|
|
76
|
+
if (!object(result) || !Array.isArray(result.content) || (result.isError !== undefined && typeof result.isError !== 'boolean')) {
|
|
77
|
+
throw new Error(`Malformed MCP result from '${id}'.`);
|
|
78
|
+
}
|
|
79
|
+
const content = result.content.map((part: unknown) => {
|
|
80
|
+
if (object(part) && part.type === 'text' && typeof part.text === 'string') return { type: 'text' as const, text: part.text };
|
|
81
|
+
if (object(part) && part.type === 'image' && typeof part.data === 'string' && typeof part.mimeType === 'string') {
|
|
82
|
+
if (!imageTypes.has(part.mimeType) || part.data.length > maximumImageLength) return { type: 'text' as const, text: `[image omitted: unsupported type or larger than ${maximumImageLength / 1024 / 1024} MiB]` };
|
|
83
|
+
return { type: 'image' as const, data: part.data, mimeType: part.mimeType };
|
|
84
|
+
}
|
|
85
|
+
return { type: 'text' as const, text: `[unsupported ${object(part) ? String(part.type) : 'unknown'} content omitted]` };
|
|
86
|
+
});
|
|
87
|
+
const maximumCharacters = 32 * 1024;
|
|
88
|
+
const bounded = content.map(part => part.type === 'text' && part.text.length > maximumCharacters
|
|
89
|
+
? { ...part, text: `${part.text.slice(0, maximumCharacters)}\n[display truncated]` }
|
|
90
|
+
: part);
|
|
91
|
+
return { content: bounded, details: { isError: result.isError === true, structuredContent: result.structuredContent } };
|
|
92
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { object, readJson, resolveProfiles, supportedSchema } from './configuration.ts';
|
|
6
|
+
import type { CorpusServer, ServerSelection } from './CorpusServer.ts';
|
|
7
|
+
|
|
8
|
+
const validId = /^[A-Za-z0-9_-]+$/;
|
|
9
|
+
|
|
10
|
+
function isSelected(entry: Record<string, unknown>, selected: Set<string>): boolean {
|
|
11
|
+
return Array.isArray(entry.profiles) && entry.profiles.some(profile => typeof profile === 'string' && selected.has(profile));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function problem(entry: Record<string, unknown>): string | undefined {
|
|
15
|
+
if (typeof entry.id !== 'string' || !validId.test(entry.id)) return 'the id must match ^[A-Za-z0-9_-]+$';
|
|
16
|
+
if (entry.transport !== 'stdio') return 'only the stdio transport is supported';
|
|
17
|
+
if (typeof entry.command !== 'string' || !entry.command) return 'the command is missing or empty';
|
|
18
|
+
if (!Array.isArray(entry.args) || !entry.args.every(argument => typeof argument === 'string')) return 'args must be an array of strings';
|
|
19
|
+
if (entry.env !== undefined && (!object(entry.env) || !Object.values(entry.env).every(value => typeof value === 'string'))) {
|
|
20
|
+
return 'env must be an object of literal string values';
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `mcpServers.<id>.enabled`: false opts out, a non-boolean is an error for that server only. */
|
|
26
|
+
function optOut(overrides: Record<string, unknown>, id: string): 'enabled' | 'disabled' | string {
|
|
27
|
+
const override = overrides[id];
|
|
28
|
+
if (override === undefined) return 'enabled';
|
|
29
|
+
if (!object(override)) return 'the mcpServers configuration must be an object';
|
|
30
|
+
if (override.enabled === undefined || override.enabled === true) return 'enabled';
|
|
31
|
+
return override.enabled === false ? 'disabled' : 'mcpServers enabled must be a boolean';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Select every stdio server whose profiles intersect the explicitly listed profiles (never composed ones), except Screenplay which keeps its own path. */
|
|
35
|
+
export function selectedCorpusServers(project: string, corpus: string): ServerSelection {
|
|
36
|
+
const selection: ServerSelection = { servers: [], messages: [] };
|
|
37
|
+
const resolution = resolveProfiles(project, corpus);
|
|
38
|
+
if (!resolution) return selection;
|
|
39
|
+
const descriptor = readJson(join(corpus, 'mcp-servers.json'));
|
|
40
|
+
if (!supportedSchema(descriptor.schemaVersion)) {
|
|
41
|
+
selection.messages.push(`Cratis MCP descriptor schemaVersion '${String(descriptor.schemaVersion)}' is not supported; no corpus MCP servers were loaded.`);
|
|
42
|
+
return selection;
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(descriptor.servers)) {
|
|
45
|
+
selection.messages.push('Cratis MCP descriptor has no servers list; no corpus MCP servers were loaded.');
|
|
46
|
+
return selection;
|
|
47
|
+
}
|
|
48
|
+
const overrides = resolution.configuration.mcpServers;
|
|
49
|
+
if (overrides !== undefined && !object(overrides)) {
|
|
50
|
+
selection.messages.push('Invalid mcpServers configuration; no corpus MCP servers were loaded.');
|
|
51
|
+
return selection;
|
|
52
|
+
}
|
|
53
|
+
const entries = descriptor.servers.filter(object);
|
|
54
|
+
const counts = new Map<unknown, number>();
|
|
55
|
+
entries.forEach(entry => counts.set(entry.id, (counts.get(entry.id) ?? 0) + 1));
|
|
56
|
+
const reported = new Set<unknown>();
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (entry.id === 'screenplay') continue;
|
|
59
|
+
// An entry without profiles can never be selected, so it is ignored like any other unselected entry.
|
|
60
|
+
if (!isSelected(entry, resolution.explicit)) continue;
|
|
61
|
+
const label = `MCP server '${String(entry.id)}'`;
|
|
62
|
+
if (counts.get(entry.id)! > 1) {
|
|
63
|
+
if (!reported.has(entry.id)) selection.messages.push(`${label} was skipped: the descriptor declares the id more than once.`);
|
|
64
|
+
reported.add(entry.id);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const invalid = problem(entry);
|
|
68
|
+
if (invalid) { selection.messages.push(`${label} was skipped: ${invalid}.`); continue; }
|
|
69
|
+
const id = entry.id as string;
|
|
70
|
+
const state = object(overrides) ? optOut(overrides, id) : 'enabled';
|
|
71
|
+
if (state === 'disabled') continue;
|
|
72
|
+
if (state !== 'enabled') { selection.messages.push(`${label} was skipped: ${state}.`); continue; }
|
|
73
|
+
selection.servers.push({ id, command: entry.command as string, args: entry.args as string[], env: { ...(entry.env as Record<string, string> | undefined) } });
|
|
74
|
+
}
|
|
75
|
+
return selection;
|
|
76
|
+
}
|
|
@@ -247,6 +247,10 @@ store gets replayed. A failed partition you have not yet explained is not a
|
|
|
247
247
|
thing to clear: read it, fix the handler, then replay. Clearing it first
|
|
248
248
|
destroys the evidence and the same failure returns on the next event.
|
|
249
249
|
|
|
250
|
+
## Compare a namespace with its model
|
|
251
|
+
|
|
252
|
+
`cratis screenplay conform-namespace` is read-only and sits outside the `chronicle` group and its guard: it reads the registry, observers and event metadata of one namespace and compares them with a `.play` model. Use it for a post-deploy check; the procedure and exit codes are in `cratis-screenplay-render-and-gap-fill` [post-deploy check](../cratis-screenplay-render-and-gap-fill/references/post-deploy-check.md). Its exit `4` is a refused read: obtain a context with read access, never widen permissions.
|
|
253
|
+
|
|
250
254
|
## What the CLI cannot do
|
|
251
255
|
|
|
252
256
|
There is no `delete`, `purge`, `redact`, or `revise` command. Event redaction
|
|
@@ -205,6 +205,7 @@ Read [references/neighbouring-skills.md](references/neighbouring-skills.md) when
|
|
|
205
205
|
- `references/renderability-gate.md` - classify the probe's codes, estimate the blast radius.
|
|
206
206
|
- `references/delivery-protocol.md` - inputs, commands, reading results, `Customizations/`.
|
|
207
207
|
- `references/gap-fill-handoff.md` - the brief for `slice-implementer`, fallback record flow.
|
|
208
|
+
- `references/post-deploy-check.md` - after deployment, compare the model with a running namespace using `cratis screenplay conform-namespace`; read-only, with its exit codes.
|
|
208
209
|
- `references/drift-and-fallback-record.md` - drift, publication refusals, the fallback record, code changed since verification.
|
|
209
210
|
- Read `references/hand-written-changes.md` when investigating hand-written changes against the contract; it defines finding kinds, resolution routing and the report.
|
|
210
211
|
- Read `references/worked-example.md` when preparing the classification and report for a refused render; it contains a complete model.
|
package/package/corpus/skills/cratis-screenplay-render-and-gap-fill/references/post-deploy-check.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<!-- Copyright (c) Cratis. All rights reserved. -->
|
|
2
|
+
<!-- Licensed under the MIT license. See LICENSE file in the project root for full license information. -->
|
|
3
|
+
|
|
4
|
+
# Post-deploy check: does the running namespace match the model
|
|
5
|
+
|
|
6
|
+
After a render is delivered and deployed, `cratis screenplay conform-namespace` compares the
|
|
7
|
+
compiled model with what one running Chronicle namespace holds. Render verification and
|
|
8
|
+
`conform` never see the running store; this check does. How to read its verdict is in the
|
|
9
|
+
toolchain's [observation comparison](../../cratis-screenplay-toolchain/references/observation-comparison.md),
|
|
10
|
+
including the CLI version it needs.
|
|
11
|
+
|
|
12
|
+
## Run it
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
cratis screenplay conform-namespace <model-root> --event-store <store> --namespace <namespace> -o json-compact
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- Pass the model root (the root `.play` file or the folder of one application), never a single
|
|
19
|
+
fragment of a folder.
|
|
20
|
+
- Name the event store and namespace explicitly. The built-in default is never assumed, and a
|
|
21
|
+
name differing only in case is reported, not chosen for you.
|
|
22
|
+
- `--sequence` selects another sequence (default `event-log`); `--from`, `--to` and `--max-events`
|
|
23
|
+
bound the scan (default `100000` events, up to the tail captured at the start).
|
|
24
|
+
- Add `--fail-on-drift` in a pipeline so the exit code carries the verdict.
|
|
25
|
+
|
|
26
|
+
## Authorization and effect
|
|
27
|
+
|
|
28
|
+
The command is read-only: it never replays observers, appends events or changes the model, and
|
|
29
|
+
it is not a `chronicle` command, so the Chronicle mutation guard does not apply. It still needs a
|
|
30
|
+
connection to the target server with permission to read the event type registry, observers and
|
|
31
|
+
event metadata. Use the context the user named; resolve it as in `cratis-chronicle-cli-operations`.
|
|
32
|
+
Do not log in, switch contexts or point at another environment to make it work. A production
|
|
33
|
+
namespace is checked only when the request named it.
|
|
34
|
+
|
|
35
|
+
## Act on the result
|
|
36
|
+
|
|
37
|
+
| Exit | Meaning | Act |
|
|
38
|
+
| --- | --- | --- |
|
|
39
|
+
| `0` | The comparison ran | Read `hasDrift` and the findings; drift alone does not fail without `--fail-on-drift` |
|
|
40
|
+
| `7` | Known drift (with `--fail-on-drift`) | Triage each Drift finding: fix the model through the model workflow, or fix the deployment; never edit a rendered file to silence it |
|
|
41
|
+
| `8` | Inconclusive (with `--fail-on-drift`), or the scan was interrupted | Report inconclusive; widen coverage or wait for lagging reactors, then rerun once |
|
|
42
|
+
| `5` | Model errors, missing store, namespace or sequence, invalid bounds | Fix the input; nothing was compared |
|
|
43
|
+
| `2` | Server unreachable | Report the connection failure; do not retry through another route |
|
|
44
|
+
| `3` | Chronicle refused or failed a read | Report it; no snapshot was built |
|
|
45
|
+
| `4` | Authorization missing for a read | Ask the user for a context with read access; do not widen permissions yourself |
|
|
46
|
+
|
|
47
|
+
Exit `4` for a refused read and exit `8` for an interrupted scan require Cratis CLI 3.89.1 or later
|
|
48
|
+
(`cratis --version`). On an older CLI, treat any exit other than `0` and `7` as "not compared".
|
|
49
|
+
|
|
50
|
+
A failed read is never an empty store.
|
|
51
|
+
|
|
52
|
+
## Report
|
|
53
|
+
|
|
54
|
+
Record the verdict next to the deployment: model identity, event store, namespace, sequence,
|
|
55
|
+
examined range against the captured tail, `Complete`, Drift findings and Unknown reasons. A
|
|
56
|
+
partial or inconclusive result is reported as such, never as a pass. Remediation that changes
|
|
57
|
+
the model or code returns to the render and gap-fill procedure.
|
|
@@ -65,7 +65,7 @@ superseded statement needs context; exceptions do not change the contract.
|
|
|
65
65
|
| | Standalone `screenplay` | `cratis screenplay ...` |
|
|
66
66
|
| --- | --- | --- |
|
|
67
67
|
| Check root | `screenplay <root> --warnaserror --no-color` | `cratis screenplay validate <root> --warnings-as-errors -o json-compact` |
|
|
68
|
-
| Scope/checks | `--scope <address>`, `--check <names>`; a scoped pass is not whole-application success | `validate --executable` (bundled binder, no write), `--check <names>`, `--scope`; `render --scope`/`--check`; `cratis screenplay conform` |
|
|
68
|
+
| Scope/checks | `--scope <address>`, `--check <names>`; a scoped pass is not whole-application success | `validate --executable` (bundled binder, no write), `--check <names>`, `--scope`; `render --scope`/`--check`; `cratis screenplay conform`, `cratis screenplay conform-namespace` |
|
|
69
69
|
| Declared gate | `screenplay <root> --print-gate`, `--format json`, `--no-gate-file` | `validate <root> --print-gate`, `--no-gate-file`; the same `screenplay.json` |
|
|
70
70
|
| Reference execution | `screenplay test <root>` or MCP `run-specifications` | Discover bundled MCP capability; target tests are not V4 |
|
|
71
71
|
| MCP | `screenplay mcp <root>`; discover schemas | `cratis screenplay mcp [root]`; registered by `cratis ai install` |
|
|
@@ -96,6 +96,8 @@ for MCP initialization, source identity and reporting. Without MCP, report
|
|
|
96
96
|
version, source identity and any blocking codes; MCP `modelRevision` is semantic,
|
|
97
97
|
not the source identity.
|
|
98
98
|
|
|
99
|
+
Read [observation comparison](references/observation-comparison.md) when asking whether a running namespace holds what the model declares, or when reading a `conform-namespace` verdict. It differs from model comparison (model versus model) and `conform` (model versus code).
|
|
100
|
+
|
|
99
101
|
## Subsets (write from the one your mode needs)
|
|
100
102
|
|
|
101
103
|
- **Design**: every construct family parses; use it to model first. Compiled shapes:
|
package/package/corpus/skills/cratis-screenplay-toolchain/references/observation-comparison.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<!-- Copyright (c) Cratis. All rights reserved. -->
|
|
2
|
+
<!-- Licensed under the MIT license. See LICENSE file in the project root for full license information. -->
|
|
3
|
+
|
|
4
|
+
# Observation comparison: model versus running store
|
|
5
|
+
|
|
6
|
+
Requires Cratis CLI 3.85.0 or later (`cratis --version`) for `cratis screenplay conform-namespace`, and
|
|
7
|
+
3.89.1 or later for the refused-read exit `4` and clean Ctrl+C handling described below; older CLIs lack
|
|
8
|
+
the verb. Without it, report "observation comparison not run" and do not substitute `conform`.
|
|
9
|
+
|
|
10
|
+
## Three comparisons, three questions
|
|
11
|
+
|
|
12
|
+
| Comparison | Compares | Answers | Does not answer |
|
|
13
|
+
| --- | --- | --- | --- |
|
|
14
|
+
| Model comparison (`semantic-diff`) | two authored models | what changed between revisions | whether anything runs |
|
|
15
|
+
| `cratis screenplay conform` | a model with source code | whether hand-written code realizes the slice | whether the deployed system did it |
|
|
16
|
+
| Observation comparison (`cratis screenplay conform-namespace`) | a compiled model with a bounded snapshot of one running Chronicle namespace | do the observed event relationships agree with the model | payload agreement, behavior, specifications passing |
|
|
17
|
+
|
|
18
|
+
Reach for observation comparison after a deploy, or when a namespace may have diverged from the
|
|
19
|
+
model: are the declared event types registered, were undeclared events appended, did each command
|
|
20
|
+
and reactor append what the model says it produces, and are the declared reactions subscribed.
|
|
21
|
+
It reads event metadata only, never payloads, event source ids or command values.
|
|
22
|
+
|
|
23
|
+
## Read the verdict
|
|
24
|
+
|
|
25
|
+
The host reports three verdict values and a separate completeness flag.
|
|
26
|
+
|
|
27
|
+
| `HasDrift` | Meaning | Action |
|
|
28
|
+
| --- | --- | --- |
|
|
29
|
+
| true | Known drift exists, even if other evidence is incomplete | Triage each Drift finding |
|
|
30
|
+
| false | The comparison was complete and found no drift | Report no drift, with the scope |
|
|
31
|
+
| null (omitted in JSON, `inconclusive` in human output) | Drift is neither established nor ruled out | Report inconclusive; never turn it into "no drift" |
|
|
32
|
+
|
|
33
|
+
`Complete` is true only when the scan covered the sequence fully **and** no finding is Unknown.
|
|
34
|
+
A report can have `HasDrift` true and `Complete` false.
|
|
35
|
+
|
|
36
|
+
## Finding outcomes
|
|
37
|
+
|
|
38
|
+
| Outcome | Reading |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| Drift | The store disagrees with the model on something the evidence establishes |
|
|
41
|
+
| Informational | A difference that is not a contradiction, such as a declared event that has not yet been appended |
|
|
42
|
+
| Unknown | The evidence cannot establish agreement or disagreement; the reason says why |
|
|
43
|
+
| Excluded | Outside this application, such as a foreign event or a platform consumer |
|
|
44
|
+
|
|
45
|
+
Typical Drift: a declared event type missing from the registry, an undeclared event appended,
|
|
46
|
+
an undeclared generation, a mapped producer appending outside its declared productions, or a
|
|
47
|
+
declared reaction with no subscription.
|
|
48
|
+
|
|
49
|
+
## Unknown reasons
|
|
50
|
+
|
|
51
|
+
| Reason | What to do |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `PartialCoverage` | Widen the range (`--from`, `--to`, `--max-events`) and rerun |
|
|
54
|
+
| `UnattributedEvents` | Events without a command or reactor in their causation; the producer cannot be judged |
|
|
55
|
+
| `UnmappedProducer`, `UnmappedConstruct`, `AmbiguousIdentity` | The model name did not map to exactly one observed identity; do not guess one |
|
|
56
|
+
| `OpaqueReaction`, `ConditionalProduction`, `DescribedHandler`, `NonEventTrigger` | The model does not state the production unconditionally, so absence proves nothing |
|
|
57
|
+
| `ConsumerBehind` | The reactor has not reached the tail; wait for it and rerun |
|
|
58
|
+
| `ConsumerKindNotCompared` | Projections and reducers are not compared; their subscriptions are never evidence either way |
|
|
59
|
+
|
|
60
|
+
## Pitfalls
|
|
61
|
+
|
|
62
|
+
- "No matching appends within coverage" does not mean the command or reactor never ran. It is
|
|
63
|
+
Informational: no attributed event shows that it ran inside the examined range.
|
|
64
|
+
- Scanned absence under partial coverage is Unknown, never drift. Registry checks are the
|
|
65
|
+
exception: the event-type registry is read in full, so a declared event that is not registered
|
|
66
|
+
(`EventNotRegistered`) is drift whatever the coverage. A missing production is drift only
|
|
67
|
+
with full coverage, an unambiguous producer mapping, another event attributed to that producer,
|
|
68
|
+
and no unattributed appends of the expected type.
|
|
69
|
+
- Presence is stronger than absence: an undeclared event that was appended is drift even under
|
|
70
|
+
partial coverage.
|
|
71
|
+
- A clean result proves only the relationships it checked. Read the report's not-established
|
|
72
|
+
statements; they list the limits.
|
|
73
|
+
- Event generations are approximated: each appended event counts against the highest registered
|
|
74
|
+
generation of its type.
|
|
75
|
+
- One sequence is compared per run, `event-log` by default.
|
|
76
|
+
|
|
77
|
+
## Report it
|
|
78
|
+
|
|
79
|
+
State the verdict value, `Complete`, the event store, namespace and sequence, the examined range
|
|
80
|
+
against the captured tail, each Drift finding with its statement, and each Unknown reason. The
|
|
81
|
+
post-deploy procedure and its exit codes are in
|
|
82
|
+
[post-deploy check](../../cratis-screenplay-render-and-gap-fill/references/post-deploy-check.md).
|
|
83
|
+
Run the comparison through the CLI; no MCP tool offers it.
|