@cratis/pi 3.0.0 → 3.1.1

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.
Files changed (25) hide show
  1. package/package/corpus/agents/orchestrator.md +1 -1
  2. package/package/corpus/harnesses/pi/extensions/cratis-mcp/ConnectionFailure.ts +11 -0
  3. package/package/corpus/harnesses/pi/extensions/cratis-mcp/DiscoveredTool.ts +12 -0
  4. package/package/corpus/harnesses/pi/extensions/cratis-mcp/PendingRequest.ts +10 -0
  5. package/package/corpus/harnesses/pi/extensions/cratis-mcp/StdioConnection.ts +160 -0
  6. package/package/corpus/harnesses/pi/extensions/cratis-mcp/configuration.ts +102 -0
  7. package/package/corpus/harnesses/pi/extensions/cratis-mcp/index.ts +149 -0
  8. package/package/corpus/harnesses/pi/extensions/cratis-mcp/process.ts +24 -0
  9. package/package/corpus/harnesses/pi/extensions/cratis-mcp/protocol.ts +67 -0
  10. package/package/corpus/mcp-servers.json +14 -0
  11. package/package/corpus/prompts/write-documentation.prompt.md +20 -11
  12. package/package/corpus/rules/ai-distribution.md +20 -1
  13. package/package/corpus/rules/documentation-structure-and-formatting.md +1 -1
  14. package/package/corpus/rules/documentation.md +11 -15
  15. package/package/corpus/rules/profiles.md +8 -1
  16. package/package/corpus/rules/writing-cratis-docs.md +2 -2
  17. package/package/corpus/skills/cratis-documentation-writing/SKILL.md +18 -14
  18. package/package/corpus/skills/cratis-engineering-docs-authoring/SKILL.md +3 -3
  19. package/package/corpus/skills/cratis-screenplay-model-authoring/SKILL.md +122 -258
  20. package/package/corpus/skills/cratis-screenplay-model-authoring/references/language-reference.md +112 -0
  21. package/package/corpus/skills/cratis-screenplay-model-authoring/references/mcp-tools.md +72 -0
  22. package/package/corpus/skills/cratis-screenplay-model-authoring/verification.json +20 -0
  23. package/package/corpus/skills/cratis-technical-examples/SKILL.md +15 -0
  24. package/package/profile-catalog.json +1 -0
  25. package/package.json +2 -1
@@ -115,7 +115,7 @@ When you receive a goal:
115
115
  - [ ] [security-reviewer] Security review of all changed files
116
116
 
117
117
  ### Phase 5 — Documentation (if applicable)
118
- - [ ] [write-documentation skill] Document <feature/concept>
118
+ - [ ] [documentation] Document <feature/concept> in its authored source with cratis-documentation-writing (or cratis-engineering-docs-authoring) and cratis-technical-examples for code, when the repository's profiles install them; otherwise follow the repository's own documentation rules
119
119
  ```
120
120
 
121
121
  ---
@@ -0,0 +1,11 @@
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 stopped connection is never restarted or a request retried implicitly. */
5
+ export class ConnectionFailure extends Error {
6
+ constructor(message: string, readonly outcomeUnknown = false) {
7
+ super(outcomeUnknown
8
+ ? `${message} Source mutation outcome is unknown. Do not retry apply/recovery. Inspect workspace-state in a new explicitly opened session before deciding on recovery.`
9
+ : message);
10
+ }
11
+ }
@@ -0,0 +1,12 @@
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
+
6
+ export interface DiscoveredTool {
7
+ name: string;
8
+ nativeName: string;
9
+ description: string;
10
+ parameters: TSchema;
11
+ mutation: boolean;
12
+ }
@@ -0,0 +1,10 @@
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
+ export interface PendingRequest {
5
+ id: number;
6
+ mutation: boolean;
7
+ resolve(value: unknown): void;
8
+ reject(error: Error): void;
9
+ cleanup(): void;
10
+ }
@@ -0,0 +1,160 @@
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 { ChildProcessWithoutNullStreams } from 'node:child_process';
5
+ import { ConnectionFailure } from './ConnectionFailure.ts';
6
+ import { object } from './configuration.ts';
7
+ import type { PendingRequest } from './PendingRequest.ts';
8
+
9
+ /** Bounded newline JSON-RPC transport for Screenplay's sequential stdio subset. */
10
+ export class StdioConnection {
11
+ private _buffer = Buffer.alloc(0);
12
+ private _sequence = 0;
13
+ private _pending?: PendingRequest;
14
+ private _failure?: ConnectionFailure;
15
+ private _termination?: ReturnType<typeof setTimeout>;
16
+ private readonly _maximumBytes = 8 * 1024 * 1024;
17
+
18
+ constructor(private readonly _child: ChildProcessWithoutNullStreams, private readonly _timeout = 30_000) {
19
+ _child.stdout.on('data', this.receive);
20
+ _child.stdout.on('end', this.ended);
21
+ _child.stdout.on('error', this.streamFailed);
22
+ _child.stdin.on('error', this.streamFailed);
23
+ // Drain without retaining or showing raw server logs/protocol in the host UI.
24
+ _child.stderr.on('data', this.drain);
25
+ _child.stderr.on('error', this.streamFailed);
26
+ _child.on('error', this.spawnFailed);
27
+ _child.once('close', this.closed);
28
+ }
29
+
30
+ get stopped(): boolean { return this._failure !== undefined; }
31
+ get outcomeUnknown(): boolean { return this._failure?.outcomeUnknown === true; }
32
+
33
+ async request(method: string, params: Record<string, unknown>, signal?: AbortSignal, mutation = false): Promise<unknown> {
34
+ if (this._failure) throw this._failure;
35
+ if (signal?.aborted) throw new ConnectionFailure('Screenplay request cancelled before sending.');
36
+ if (this._pending) throw new ConnectionFailure('Screenplay is busy; parallel requests are not queued.');
37
+ const id = ++this._sequence;
38
+ const line = this.encode({ jsonrpc: '2.0', id, method, params });
39
+ return new Promise((resolve, reject) => {
40
+ const abort = () => this.stop('Screenplay request cancelled after sending.');
41
+ const timer = setTimeout(() => this.stop('Screenplay request timed out.'), this._timeout);
42
+ this._pending = {
43
+ id, mutation, resolve, reject,
44
+ cleanup: () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); },
45
+ };
46
+ signal?.addEventListener('abort', abort, { once: true });
47
+ try {
48
+ this._child.stdin.write(line, error => { if (error) this.stop('Screenplay input pipe failed.'); });
49
+ } catch {
50
+ this.stop('Screenplay input pipe failed.');
51
+ }
52
+ });
53
+ }
54
+
55
+ notify(method: string): void {
56
+ if (this._failure) throw this._failure;
57
+ try {
58
+ this._child.stdin.write(this.encode({ jsonrpc: '2.0', method }), error => { if (error) this.stop('Screenplay notification failed.'); });
59
+ } catch {
60
+ this.stop('Screenplay notification failed.');
61
+ throw this._failure;
62
+ }
63
+ }
64
+
65
+ dispose(): void { this.stop('Screenplay session closed.'); }
66
+
67
+ private encode(value: unknown): string {
68
+ const line = `${JSON.stringify(value)}\n`;
69
+ if (Buffer.byteLength(line) > this._maximumBytes) throw new ConnectionFailure('Screenplay request exceeds the 8 MiB transport limit.');
70
+ return line;
71
+ }
72
+
73
+ private readonly receive = (chunk: Buffer): void => {
74
+ if (this.stopped) return;
75
+ // Stream chunks can contain many lines; bound each unfinished line before concatenation.
76
+ let start = 0;
77
+ for (let index = 0; index < chunk.length; index++) {
78
+ if (chunk[index] !== 10) continue;
79
+ if (!this.append(chunk.subarray(start, index))) return;
80
+ try {
81
+ const line = new TextDecoder('utf-8', { fatal: true }).decode(this._buffer);
82
+ this._buffer = Buffer.alloc(0);
83
+ this.accept(line);
84
+ } catch {
85
+ this.stop('Screenplay returned invalid UTF-8 protocol data.');
86
+ }
87
+ if (this.stopped) return;
88
+ start = index + 1;
89
+ }
90
+ this.append(chunk.subarray(start));
91
+ };
92
+
93
+ private append(chunk: Buffer): boolean {
94
+ if (this._buffer.length + chunk.length > this._maximumBytes) {
95
+ this.stop('Screenplay response exceeds the 8 MiB transport limit.');
96
+ return false;
97
+ }
98
+ this._buffer = Buffer.concat([this._buffer, chunk]);
99
+ return true;
100
+ }
101
+
102
+ private accept(line: string): void {
103
+ try {
104
+ const message: unknown = JSON.parse(line);
105
+ if (!object(message) || message.jsonrpc !== '2.0') throw new Error();
106
+ if ('method' in message) {
107
+ // This pinned server does not send requests or changing tool lists.
108
+ throw new Error();
109
+ }
110
+ const pending = this._pending;
111
+ if (!pending || message.id !== pending.id || ('result' in message) === ('error' in message)) throw new Error();
112
+ if ('error' in message && (!object(message.error) || typeof message.error.code !== 'number' || typeof message.error.message !== 'string')) throw new Error();
113
+ this._pending = undefined;
114
+ pending.cleanup();
115
+ if (object(message.error)) {
116
+ pending.reject(new ConnectionFailure(`Screenplay protocol error ${message.error.code}: ${message.error.message}`, pending.mutation));
117
+ // A server failure during mutation is not evidence that nothing was written.
118
+ if (pending.mutation) this.stop('Screenplay mutation failed with a protocol error.', true);
119
+ } else {
120
+ pending.resolve(message.result);
121
+ }
122
+ } catch {
123
+ this.stop('Screenplay returned malformed or unexpected JSON-RPC.');
124
+ }
125
+ }
126
+
127
+ private stop(message: string, outcomeUnknown = this._pending?.mutation === true): void {
128
+ if (this._failure) return;
129
+ this._failure = new ConnectionFailure(message, outcomeUnknown);
130
+ const pending = this._pending;
131
+ this._pending = undefined;
132
+ pending?.cleanup();
133
+ pending?.reject(this._failure);
134
+ this._buffer = Buffer.alloc(0);
135
+ this._child.stdout.removeListener('data', this.receive);
136
+ this._child.stdout.removeListener('end', this.ended);
137
+ this._child.stdout.resume();
138
+ this._child.stdin.end();
139
+ if (this._child.exitCode === null && this._child.signalCode === null) {
140
+ this._child.kill('SIGTERM');
141
+ // Deadline escalation, not a completion wait. Cleared on the child's close signal.
142
+ this._termination = setTimeout(() => this._child.kill('SIGKILL'), 1000);
143
+ this._termination.unref();
144
+ }
145
+ }
146
+
147
+ private readonly drain = (): void => { /* Drain server stderr without exposing it as a protocol result. */ };
148
+ private readonly ended = (): void => this.stop('Screenplay output closed before session shutdown.');
149
+ private readonly streamFailed = (): void => this.stop('Screenplay stdio failed.');
150
+ private readonly spawnFailed = (): void => this.stop('Cannot launch native cratis screenplay mcp. Install/update the Cratis CLI through its normal channel; this extension never downloads a runtime.');
151
+ 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.');
153
+ clearTimeout(this._termination);
154
+ this._child.stdout.removeListener('error', this.streamFailed);
155
+ this._child.stdin.removeListener('error', this.streamFailed);
156
+ this._child.stderr.removeListener('error', this.streamFailed);
157
+ this._child.stderr.removeListener('data', this.drain);
158
+ this._child.removeListener('error', this.spawnFailed);
159
+ };
160
+ }
@@ -0,0 +1,102 @@
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 { lstatSync, readFileSync, realpathSync } from 'node:fs';
5
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
6
+
7
+ export function object(value: unknown): value is Record<string, unknown> {
8
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
9
+ }
10
+
11
+ function readJson(path: string): Record<string, unknown> {
12
+ if (lstatSync(path).size > 1024 * 1024) throw new Error(`Cratis configuration is too large: ${path}`);
13
+ const value: unknown = JSON.parse(readFileSync(path, 'utf8'));
14
+ if (!object(value)) throw new Error(`Expected a JSON object: ${path}`);
15
+ return value;
16
+ }
17
+
18
+ function strings(value: unknown): string[] {
19
+ if (value === undefined) return [];
20
+ if (!Array.isArray(value) || !value.every(item => typeof item === 'string')) throw new Error('Expected a Cratis profile/language list.');
21
+ return value;
22
+ }
23
+
24
+ /** Resolve through existing directories only, refusing every source-root symlink. Never creates a directory. */
25
+ export function physicalRoot(project: string, root: string): string {
26
+ if (!root || isAbsolute(root) || root.includes('\0')) throw new Error('Screenplay root must be a nonempty project-relative directory.');
27
+ const physicalProject = realpathSync(project);
28
+ const candidate = resolve(physicalProject, root);
29
+ const local = relative(physicalProject, candidate);
30
+ if (!local || local === '..' || local.startsWith(`..${sep}`) || isAbsolute(local)) {
31
+ throw new Error('Screenplay root must be strictly inside the project.');
32
+ }
33
+ let current = physicalProject;
34
+ for (const part of local.split(sep)) {
35
+ current = join(current, part);
36
+ let info;
37
+ try {
38
+ info = lstatSync(current);
39
+ } catch (error) {
40
+ if (object(error) && error.code === 'ENOENT') throw new Error('Screenplay root is absent. Run cratis ai install or explicitly create the configured model directory; discovery never creates it.');
41
+ throw error;
42
+ }
43
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('Screenplay root must contain only physical directories, not symlinks.');
44
+ }
45
+ return current;
46
+ }
47
+
48
+ /** Select only the shipped Screenplay descriptor, never project-supplied commands or environments. */
49
+ export function selectedServer(project: string, corpus: string) {
50
+ let configuration: Record<string, unknown>;
51
+ try {
52
+ configuration = readJson(join(project, '.cratis', 'ai.json'));
53
+ } catch (error) {
54
+ if (object(error) && error.code === 'ENOENT') return undefined;
55
+ throw error;
56
+ }
57
+ const requested = strings(configuration.profiles);
58
+ if (!requested.length) return undefined;
59
+ let catalog: Record<string, unknown>;
60
+ try {
61
+ catalog = readJson(join(corpus, 'profile-catalog.json'));
62
+ } catch (error) {
63
+ if (!object(error) || error.code !== 'ENOENT') throw error;
64
+ catalog = readJson(join(corpus, '..', 'profile-catalog.json'));
65
+ }
66
+ const profiles = [catalog.publicProfiles, catalog.engineeringProfiles].flatMap(group => {
67
+ if (!Array.isArray(group) || !group.every(object)) throw new Error('Invalid Cratis profile catalog.');
68
+ return group;
69
+ });
70
+ const languages = new Set(strings(configuration.languages));
71
+ const selected = new Set<string>();
72
+ const select = (id: string, explicit: boolean): void => {
73
+ if (selected.has(id)) return;
74
+ const profile = profiles.find(candidate => candidate.id === id);
75
+ if (!profile) throw new Error(`Unknown Cratis AI profile '${id}'.`);
76
+ const supported = strings(profile.languages);
77
+ if (!explicit && supported.length && !supported.some(language => language === 'language-agnostic' || languages.has(language))) return;
78
+ selected.add(id);
79
+ strings(profile.composes).forEach(child => select(child, false));
80
+ };
81
+ requested.forEach(id => select(id, true));
82
+ if (!selected.has('cratis/screenplay')) return undefined;
83
+ const overrides = configuration.mcpServers;
84
+ if (overrides !== undefined && !object(overrides)) throw new Error('Invalid mcpServers configuration.');
85
+ const screenplay = object(overrides) ? overrides.screenplay : undefined;
86
+ if (screenplay !== undefined && !object(screenplay)) throw new Error('Invalid Screenplay MCP configuration.');
87
+ if (object(screenplay) && screenplay.enabled === false) return undefined;
88
+ if (object(screenplay) && screenplay.enabled !== undefined && screenplay.enabled !== true) throw new Error('Screenplay MCP enabled must be a boolean.');
89
+ const descriptor = readJson(join(corpus, 'mcp-servers.json'));
90
+ if (descriptor.schemaVersion !== '1.0' || !Array.isArray(descriptor.servers)) throw new Error('Unsupported Cratis MCP descriptor.');
91
+ const servers = descriptor.servers.filter(server => object(server) && server.id === 'screenplay');
92
+ if (servers.length !== 1 || !object(servers[0])) throw new Error('Expected exactly one Screenplay MCP descriptor.');
93
+ const server = servers[0];
94
+ if (server.transport !== 'stdio' || server.command !== 'cratis' || JSON.stringify(server.args) !== '["screenplay","mcp"]' ||
95
+ JSON.stringify(server.profiles) !== '["cratis/screenplay"]' || server.env !== undefined) {
96
+ throw new Error('Only the distributed native Cratis Screenplay MCP command is supported.');
97
+ }
98
+ const root = object(screenplay) && screenplay.root !== undefined ? screenplay.root : server.defaultRoot;
99
+ if (typeof root !== 'string') throw new Error('Screenplay MCP root must be a project-relative string.');
100
+ const physicalProject = realpathSync(project);
101
+ return { project: physicalProject, root: physicalRoot(physicalProject, root) };
102
+ }
@@ -0,0 +1,149 @@
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 { existsSync } from 'node:fs';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
8
+ import { ConnectionFailure } from './ConnectionFailure.ts';
9
+ import { object, selectedServer } from './configuration.ts';
10
+ import { startConnection } from './process.ts';
11
+ import { discover, failedResult, mapResult } from './protocol.ts';
12
+ import type { StdioConnection } from './StdioConnection.ts';
13
+
14
+ const extensionDirectory = dirname(fileURLToPath(import.meta.url));
15
+ const bundledCorpus = resolve(extensionDirectory, '..', '..', '..', '..');
16
+
17
+ /** 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
+ let connection: StdioConnection | undefined;
20
+ let selectionKey: string | undefined;
21
+ let attempted = false;
22
+ let uncertain = false;
23
+ const registered = new Set<string>();
24
+ const paused = new Set<string>();
25
+ const failures = new Map<string, ReturnType<typeof failedResult>>();
26
+
27
+ const deactivate = (): void => {
28
+ const active = pi.getActiveTools();
29
+ active.filter(name => registered.has(name)).forEach(name => paused.add(name));
30
+ if (active.some(name => registered.has(name))) pi.setActiveTools(active.filter(name => !registered.has(name)));
31
+ };
32
+ const dispose = (): void => {
33
+ connection?.dispose();
34
+ uncertain ||= connection?.outcomeUnknown === true;
35
+ connection = undefined;
36
+ deactivate();
37
+ };
38
+ const currentSelection = (context: ExtensionContext) => selectedServer(context.cwd, corpus);
39
+ const ensureCurrent = (context: ExtensionContext): void => {
40
+ let key: string | undefined;
41
+ try { key = JSON.stringify(currentSelection(context)); }
42
+ catch (error) { dispose(); throw error; }
43
+ if (!key || key !== selectionKey) {
44
+ dispose();
45
+ throw new Error('Screenplay profile/root changed. Start a new turn to rediscover tools; never reuse old workspace handles.');
46
+ }
47
+ if (!connection || connection.stopped) throw new Error('Screenplay connection is unavailable. No request was retried; explicitly reload after inspecting any uncertain mutation.');
48
+ };
49
+ const refresh = async (context: ExtensionContext, newSession = false): Promise<void> => {
50
+ const previouslyActive = new Set([...pi.getActiveTools(), ...paused]);
51
+ let selection: ReturnType<typeof selectedServer>;
52
+ try { selection = currentSelection(context); }
53
+ catch (error) { dispose(); throw error; }
54
+ const key = JSON.stringify(selection);
55
+ if (!newSession && attempted && key === selectionKey) return;
56
+ dispose();
57
+ attempted = true;
58
+ selectionKey = key;
59
+ failures.clear();
60
+ if (!selection) return;
61
+ if (uncertain) throw new Error('Screenplay mutation outcome remains unknown. Explicitly reload, inspect workspace-state, and obtain authorization before any recovery.');
62
+ const modelRoot = selection.root;
63
+ const activeConnection = connect(selection.project);
64
+ connection = activeConnection;
65
+ try {
66
+ const tools = await discover(activeConnection);
67
+ if (connection !== activeConnection || activeConnection.stopped) throw new Error('Screenplay session ended during discovery.');
68
+ const existing = new Set(pi.getAllTools().map(tool => tool.name));
69
+ if (tools.some(tool => existing.has(tool.nativeName) && !registered.has(tool.nativeName))) throw new Error('Screenplay native tool name conflicts with another extension.');
70
+ const oldNames = new Set(registered);
71
+ for (const tool of tools) {
72
+ registered.add(tool.nativeName);
73
+ pi.registerTool({
74
+ name: tool.nativeName,
75
+ label: `Screenplay ${tool.name}`,
76
+ description: tool.description,
77
+ parameters: tool.parameters,
78
+ executionMode: 'sequential',
79
+ promptGuidelines: [
80
+ 'Use the Screenplay model-authoring skill. Read/propose/review before applying; only a direct user request authorizes apply or recovery.',
81
+ 'Use bounded pages. On an unknown mutation outcome, never retry or recover automatically; inspect workspace-state first.',
82
+ ],
83
+ async execute(toolCallId, parameters, signal, _onUpdate, toolContext) {
84
+ ensureCurrent(toolContext);
85
+ if (connection !== activeConnection || !pi.getActiveTools().includes(tool.nativeName)) throw new Error('Screenplay tool is inactive in this session.');
86
+ if (!object(parameters)) throw new Error('Screenplay arguments must be an object.');
87
+ if (signal?.aborted) throw new Error('Screenplay call cancelled before sending.');
88
+ if (tool.mutation) {
89
+ if (!toolContext.hasUI) throw new Error('Screenplay source mutation requires explicit user confirmation in Pi interactive/RPC mode.');
90
+ const argumentsText = JSON.stringify(parameters);
91
+ if (argumentsText.length > 8192) throw new Error('Mutation confirmation arguments are too large; omit includeContent and use the proposal/operation identifiers.');
92
+ const approved = await toolContext.ui.confirm(`Screenplay ${tool.name}`, `Modify source under ${modelRoot}?\n${argumentsText}\nOnly approve the exact reviewed proposal or recovery operation.`, { timeout: 60_000, signal });
93
+ if (!approved || signal?.aborted) throw new Error('Screenplay source mutation was not approved.');
94
+ ensureCurrent(toolContext);
95
+ }
96
+ let response: unknown;
97
+ try {
98
+ response = await activeConnection.request('tools/call', { name: tool.name, arguments: parameters }, signal, tool.mutation);
99
+ const result = mapResult(response);
100
+ if (result.details.isError) {
101
+ if (failures.size >= 64) failures.delete(failures.keys().next().value!);
102
+ failures.set(toolCallId, failedResult(result));
103
+ throw new Error('Screenplay reported a failed tool call.');
104
+ }
105
+ return result;
106
+ } catch (error) {
107
+ if (tool.mutation && response !== undefined && !failures.has(toolCallId)) {
108
+ uncertain = true;
109
+ dispose();
110
+ throw new ConnectionFailure('Screenplay mutation returned an invalid result.', true);
111
+ }
112
+ uncertain ||= activeConnection.outcomeUnknown;
113
+ if (activeConnection.stopped) deactivate();
114
+ throw error;
115
+ }
116
+ },
117
+ });
118
+ }
119
+ // Never re-enable a user-disabled tool. SDK allow/exclude restrictions still filter registration.
120
+ const discovered = new Set(tools.map(tool => tool.nativeName));
121
+ const next = pi.getActiveTools().filter(name => !registered.has(name) || (discovered.has(name) && (!oldNames.has(name) || previouslyActive.has(name))));
122
+ for (const name of discovered) if (oldNames.has(name) && previouslyActive.has(name)) next.push(name);
123
+ pi.setActiveTools([...new Set(next)]);
124
+ paused.clear();
125
+ } catch (error) {
126
+ if (connection === activeConnection) dispose();
127
+ else activeConnection.dispose();
128
+ throw error;
129
+ }
130
+ };
131
+
132
+ pi.on('session_start', (_event, context) => refresh(context, true));
133
+ pi.on('before_agent_start', async (_event, context) => { await refresh(context); });
134
+ pi.on('session_shutdown', () => { dispose(); failures.clear(); });
135
+ pi.on('tool_result', event => {
136
+ if (!registered.has(event.toolName)) return;
137
+ const failure = failures.get(event.toolCallId);
138
+ failures.delete(event.toolCallId);
139
+ return failure;
140
+ });
141
+ }
142
+
143
+ export default function (pi: ExtensionAPI): void {
144
+ // A managed project already loads its own bridge. Do not run a second subprocess from @cratis/pi.
145
+ const managedEntry = join(process.cwd(), '.pi', 'extensions', 'cratis-mcp', 'index.ts');
146
+ if (bundledCorpus.endsWith(join('package', 'corpus')) && existsSync(join(process.cwd(), '.cratis', 'ai.manifest.json')) && existsSync(managedEntry)) return;
147
+ const corpus = existsSync(join(bundledCorpus, 'mcp-servers.json')) ? bundledCorpus : join(process.cwd(), '.cratis', 'ai');
148
+ registerBridge(pi, corpus);
149
+ }
@@ -0,0 +1,24 @@
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 { spawn } from 'node:child_process';
5
+ import { StdioConnection } from './StdioConnection.ts';
6
+
7
+ /** Preserve OS/runtime lookup only; never copy arbitrary credentials or descriptor environment values. */
8
+ export function childEnvironment(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
9
+ const environment: NodeJS.ProcessEnv = {};
10
+ for (const key of ['PATH', 'HOME', 'USERPROFILE', 'SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'TMPDIR', 'DOTNET_ROOT', 'DOTNET_ROOT_X64', 'DOTNET_ROOT_ARM64']) {
11
+ if (source[key] !== undefined) environment[key] = source[key];
12
+ }
13
+ return environment;
14
+ }
15
+
16
+ export function startConnection(project: string, launch: typeof spawn = spawn): StdioConnection {
17
+ return new StdioConnection(launch('cratis', ['screenplay', 'mcp', '--project-root', project], {
18
+ cwd: project,
19
+ env: childEnvironment(),
20
+ shell: false,
21
+ stdio: ['pipe', 'pipe', 'pipe'],
22
+ windowsHide: true,
23
+ }));
24
+ }
@@ -0,0 +1,67 @@
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 { DiscoveredTool } from './DiscoveredTool.ts';
7
+ import type { StdioConnection } from './StdioConnection.ts';
8
+
9
+ /** Negotiate the server's implemented protocol before exposing any discovered tools. */
10
+ export async function discover(connection: StdioConnection): Promise<DiscoveredTool[]> {
11
+ const initialized = await connection.request('initialize', {
12
+ protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'cratis.pi', version: '1.0.0' },
13
+ });
14
+ if (!object(initialized) || initialized.protocolVersion !== '2025-06-18' || !object(initialized.serverInfo) ||
15
+ initialized.serverInfo.name !== 'cratis.screenplay' || !object(initialized.capabilities) || !object(initialized.capabilities.tools) ||
16
+ initialized.capabilities.tools.listChanged === true) throw new Error('Unsupported Screenplay MCP server capabilities or protocol version.');
17
+ connection.notify('notifications/initialized');
18
+ const listed = await connection.request('tools/list', {});
19
+ if (!object(listed) || !Array.isArray(listed.tools) || !listed.tools.length || listed.tools.length > 128 || listed.nextCursor !== undefined) {
20
+ throw new Error('Expected a nonempty, unpaginated Screenplay tool catalog (at most 128 tools).');
21
+ }
22
+ const names = new Set<string>();
23
+ return listed.tools.map((tool: unknown) => {
24
+ if (!object(tool) || typeof tool.name !== 'string' || !/^[a-z][a-z0-9-]{0,47}$/.test(tool.name) ||
25
+ typeof tool.description !== 'string' || tool.description.length > 8192 || !object(tool.inputSchema) || tool.inputSchema.type !== 'object') {
26
+ throw new Error('Invalid Screenplay tool name, description, or input schema.');
27
+ }
28
+ const nativeName = `screenplay_${tool.name.replaceAll('-', '_')}`;
29
+ if (names.has(nativeName)) throw new Error('Duplicate Screenplay tool name.');
30
+ names.add(nativeName);
31
+ // Hints are not authority. Only the pinned apply/recover operations may mutate source.
32
+ const mutation = tool.name === 'apply' || tool.name === 'recover-workspace';
33
+ if (!mutation && (!object(tool.annotations) || tool.annotations.readOnlyHint !== true || tool.annotations.destructiveHint === true)) {
34
+ throw new Error(`Unrecognized source-mutating Screenplay tool '${tool.name}'; update the reviewed bridge.`);
35
+ }
36
+ return { name: tool.name, nativeName, description: tool.description, parameters: tool.inputSchema as TSchema, mutation };
37
+ });
38
+ }
39
+
40
+ /** Keep structured data once in details and never present server failures as success. */
41
+ export function mapResult(result: unknown) {
42
+ if (!object(result) || !Array.isArray(result.content) || (result.isError !== undefined && typeof result.isError !== 'boolean') ||
43
+ (result.structuredContent !== undefined && !object(result.structuredContent))) throw new Error('Malformed Screenplay tool result.');
44
+ const texts = result.content.map((part: unknown) => {
45
+ if (!object(part) || part.type !== 'text' || typeof part.text !== 'string') throw new Error('Unsupported Screenplay result content; expected text.');
46
+ return part.text;
47
+ });
48
+ const fullText = texts.join('\n');
49
+ const maximumCharacters = 32 * 1024;
50
+ const truncated = fullText.length > maximumCharacters;
51
+ const text = truncated
52
+ ? `${fullText.slice(0, maximumCharacters)}\n[Screenplay display truncated. Request a smaller page or narrower view; full structured data is retained in tool details.]`
53
+ : fullText;
54
+ return {
55
+ content: [{ type: 'text' as const, text }],
56
+ details: {
57
+ structuredContent: result.structuredContent,
58
+ isError: result.isError === true,
59
+ truncated,
60
+ ...(truncated && result.structuredContent === undefined ? { fullText } : {}),
61
+ },
62
+ };
63
+ }
64
+
65
+ export function failedResult(result: ReturnType<typeof mapResult>) {
66
+ return { ...result, isError: true };
67
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "schemaVersion": "1.0",
3
+ "servers": [
4
+ {
5
+ "id": "screenplay",
6
+ "profiles": ["cratis/screenplay"],
7
+ "transport": "stdio",
8
+ "command": "cratis",
9
+ "args": ["screenplay", "mcp"],
10
+ "defaultRoot": ".cratis/screenplay",
11
+ "description": "Understand and safely author one Screenplay application through typed AST operations, reviewed proposals, durable identities, and explicit recovery."
12
+ }
13
+ ]
14
+ }
@@ -1,21 +1,30 @@
1
1
  ---
2
2
  agent: agent
3
- description: "Write documentation following the Diátaxis framework."
3
+ description: "Write reader-centered documentation using the Cratis documentation skills."
4
4
  ---
5
5
 
6
6
  # Write Documentation
7
7
 
8
- Write documentation for a feature, component, or concept. Invoke the **write-documentation** skill and follow `.cratis/ai/rules/documentation.md`.
8
+ Write documentation for a feature, component, or concept. Follow
9
+ `.cratis/ai/rules/documentation.md` and invoke **cratis-documentation-writing**.
10
+ For a Cratis product page, also use **cratis-engineering-docs-authoring**;
11
+ for samples or snippets, use **cratis-technical-examples**.
9
12
 
10
- ## Confirm first
13
+ ## Determine the reader's job
11
14
 
12
- - **Subject**, **audience**, and the **Diátaxis type** — exactly one:
13
- - **Tutorial** — guided lesson for newcomers
14
- - **How-to guide** — recipe for a specific task
15
- - **Reference** — exhaustive, terse technical description
16
- - **Explanation** — concepts, trade-offs, architecture (the *why*)
17
- - The source files to document.
15
+ From the request and neighboring authored pages, identify the reader, the
16
+ question they have, the outcome they need, and the page's primary purpose:
17
+ tutorial, how-to, explanation, or reference. Ask only when plausible choices
18
+ would materially change the result; don't require an outline approval before
19
+ ordinary drafting. Find the owning source before editing, never a synced copy.
18
20
 
19
- ## Workflow
21
+ ## Write and verify
20
22
 
21
- Clarify type/audience/scope → propose an outline → write. Active voice, present tense, second person; lead with *why*; complete and correct code examples; Mermaid diagrams for non-trivial concepts; descriptive link text; relative links that resolve. Update `toc.yml` and run the documentation verification before considering it done. The skill carries the per-page detail; don't duplicate it here.
23
+ Lead with the problem and payoff. Use active voice and show how the reader can
24
+ recognize success. A tutorial can briefly explain an observed result without
25
+ turning into a reference dump; link to deeper material. Verify framework APIs
26
+ against first-party source at the applicable version, check the sample at its
27
+ claimed scope, and run the owning repository's content and link gates. Add a
28
+ new page to the owning product's `toc.yml`
29
+ (a page missing from it silently drops out of the sidebar); otherwise update
30
+ navigation only when the page's placement or route changes.
@@ -31,6 +31,26 @@ repository through one of three channels, each with its own version semantics:
31
31
  - **Native plugin marketplaces** (Claude Code, Codex, Copilot, Cursor) expose the
32
32
  skills only, from the unpinned GitHub source.
33
33
 
34
+ ## Profile-selected MCP servers
35
+
36
+ `mcp-servers.json` declares MCP launch capabilities for selected profiles. It is
37
+ canonical corpus content, not a replacement client configuration and not an
38
+ instruction for an assistant to install or execute arbitrary software.
39
+
40
+ The Cratis CLI owns native client registration and executable hosting. Screenplay
41
+ uses `cratis screenplay mcp`, bundled in the CLI; no second global tool install
42
+ or startup download is required. Client installation must preserve unrelated
43
+ servers, settings and comments, detect conflicts, support dry-run/status, and
44
+ remove only unchanged owned entries on uninstall. Unsupported adapters must be
45
+ reported explicitly rather than presented as configured.
46
+
47
+ Project model roots and opt-outs are consumer-owned configuration. Guidance
48
+ installation never makes `.cratis/screenplay/` managed corpus content. Ordinary
49
+ model writes still require the model-authoring proposal/apply contract and the
50
+ user's in-scope request.
51
+
52
+ ## Preserve consumer ownership
53
+
34
54
  Keep existing repository-local AI files in place while a replacement corpus is
35
55
  under canary; do not restart legacy all-to-all propagation and do not delete
36
56
  legacy adapters before reviewed retirement evidence exists.
@@ -57,4 +77,3 @@ and roll back the same way — by package version where one exists, otherwise by
57
77
  reinstalling from a known-good source commit with `--source`. Never patch managed
58
78
  files under `.cratis/ai/`, generated adapters, or marketplace wrappers by hand;
59
79
  `cratis ai status` reports such drift and `update` refuses it without `--force`.
60
-