@cratis/pi 7.93.0 → 7.95.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.
Files changed (25) hide show
  1. package/README.md +14 -0
  2. package/package/corpus/harnesses/copilot/instructions/discoverability.instructions.md +34 -0
  3. package/package/corpus/harnesses/pi/extensions/cratis-mcp/CorpusServer.ts +16 -0
  4. package/package/corpus/harnesses/pi/extensions/cratis-mcp/ServerBridge.ts +113 -0
  5. package/package/corpus/harnesses/pi/extensions/cratis-mcp/StdioConnection.ts +29 -20
  6. package/package/corpus/harnesses/pi/extensions/cratis-mcp/capabilities.json +1 -0
  7. package/package/corpus/harnesses/pi/extensions/cratis-mcp/configuration.ts +26 -5
  8. package/package/corpus/harnesses/pi/extensions/cratis-mcp/index.ts +14 -5
  9. package/package/corpus/harnesses/pi/extensions/cratis-mcp/process.ts +12 -0
  10. package/package/corpus/harnesses/pi/extensions/cratis-mcp/serverProtocol.ts +92 -0
  11. package/package/corpus/harnesses/pi/extensions/cratis-mcp/servers.ts +76 -0
  12. package/package/corpus/hooks/scripts/cratis-patterns.json +24 -0
  13. package/package/corpus/prompts/pre-publish-check.prompt.md +26 -0
  14. package/package/corpus/rules/discoverability.md +33 -0
  15. package/package/corpus/rules/general.md +1 -0
  16. package/package/corpus/skills/cratis-discoverability/LICENSE +2 -0
  17. package/package/corpus/skills/cratis-discoverability/SKILL.md +73 -0
  18. package/package/corpus/skills/cratis-discoverability/references/entities.md +68 -0
  19. package/package/corpus/skills/cratis-discoverability/references/package-metadata.md +38 -0
  20. package/package/corpus/skills/cratis-discoverability/references/page-checklist.md +88 -0
  21. package/package/corpus/skills/cratis-discoverability/references/structured-data.md +62 -0
  22. package/package/corpus/skills/cratis-discoverability/scripts/verify-discoverability.mjs +107 -0
  23. package/package/corpus/skills/cratis-discoverability/verification.json +13 -0
  24. package/package/profile-catalog.json +2 -0
  25. 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,34 @@
1
+ ---
2
+ applyTo: "**/Documentation/**/*.md,**/Documentation/**/*.mdx,**/src/pages/**/*.astro,**/src/pages/**/*.md,**/src/pages/**/*.mdx,**/src/pages/**/*.html,**/src/content/**/*.md,**/src/content/**/*.mdx"
3
+ paths:
4
+ - "**/Documentation/**/*.md"
5
+ - "**/Documentation/**/*.mdx"
6
+ - "**/src/pages/**/*.astro"
7
+ - "**/src/pages/**/*.md"
8
+ - "**/src/pages/**/*.mdx"
9
+ - "**/src/pages/**/*.html"
10
+ - "**/src/content/**/*.md"
11
+ - "**/src/content/**/*.mdx"
12
+ ---
13
+ <!-- cratis-ai: generated Copilot instructions. Do not edit; rules/ is the source. -->
14
+
15
+ # Writing content that people and assistants can find
16
+
17
+ Readability comes first. If a change for search engines or AI assistants makes a page harder to read, drop the change. Voice, structure and examples stay with [writing-cratis-docs.md](./writing-cratis-docs.instructions.md), [editing-cratis-docs.md](./editing-cratis-docs.instructions.md) and [documentation-structure-and-formatting.md](./documentation-structure-and-formatting.instructions.md); this rule adds only what they do not cover. Before publishing a page, post or package, use **cratis-discoverability**.
18
+
19
+ ## Every page, post and README
20
+
21
+ - **Define first, where a definition belongs.** Landing pages, product index pages, glossary entries, READMEs and package descriptions open with one self-contained sentence: "X is a <category> for <audience> that <does>."
22
+ - **Docs tutorials, getting-started pages and explanations follow [writing-cratis-docs.md](./writing-cratis-docs.instructions.md)**: scenario first, pain before relief. Their answer-first, self-contained definition goes in the frontmatter `description`, which serves search and AI. Where it reads naturally, one definition sentence may follow the opening scenario. writing-cratis-docs wins on voice and structure.
23
+ - **Title and description are written by hand.** Title with the distinguishing term first: 50-60 characters for blog and landing pages, 20-60 for docs and reference pages; a README H1 that is the product name is fine. Description is one sentence of 60-160 characters, never over 160, and not a copy of the title or of another page's description.
24
+ - **Name things one way.** The first mention is the full name ("Cratis Chronicle"), later mentions may shorten it. Use the canonical names in the **cratis-discoverability** entity reference, and do not call the platform "Cratis.io".
25
+ - **Links name their destination.** Link text says what is behind it. Outside the docs site, link docs with the absolute canonical host `https://www.cratis.io/...`, never the bare `cratis.io`.
26
+ - **Images carry alt text.** Describe the content. Use an empty `alt=""` only for decoration.
27
+ - **No keyword stuffing, no filler for length, no text hidden from readers.** A page is done when it is complete.
28
+
29
+ ## Sites and packages
30
+
31
+ - Public pages need a canonical link, one `<h1>`, Open Graph tags, valid JSON-LD that describes visible content, a sitemap entry and a `robots.txt` that allows crawling. Add no new `HowTo` or `FAQPage` markup; keep the visible Q&A.
32
+ - `llms.txt` is an optional index. Never claim it improves ranking. See **cratis-llm-friendly-documentation**.
33
+ - Package metadata (`Description`, `PackageProjectUrl`, `PackageTags`, npm `description`, `homepage`, `keywords`) and the README opener use the same definition sentence, and the project URL points at the docs page `https://www.cratis.io/<product>/`.
34
+ - State only facts the repository supports: versions, licenses, limits. The claim gate in `BRAND.md`, `MESSAGING.md` and `SITE.md` still applies.
@@ -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('Screenplay request cancelled before sending.');
36
- if (this._pending) throw new ConnectionFailure('Screenplay is busy; parallel requests are not queued.');
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('Screenplay request cancelled after sending.');
41
- const timer = setTimeout(() => this.stop('Screenplay request timed out.'), this._timeout);
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('Screenplay input pipe failed.'); });
48
+ this._child.stdin.write(line, error => { if (error) this.stop(`${this._label} input pipe failed.`); });
49
49
  } catch {
50
- this.stop('Screenplay input pipe failed.');
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('Screenplay notification failed.'); });
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('Screenplay notification failed.');
60
+ this.stop(`${this._label} notification failed.`);
61
61
  throw this._failure;
62
62
  }
63
63
  }
64
64
 
65
- dispose(): void { this.stop('Screenplay session closed.'); }
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('Screenplay request exceeds the 8 MiB transport limit.');
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('Screenplay returned invalid UTF-8 protocol data.');
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('Screenplay response exceeds the 8 MiB transport limit.');
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(`Screenplay protocol error ${message.error.code}: ${message.error.message}`, pending.mutation));
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('Screenplay mutation failed with a protocol error.', true);
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('Screenplay returned malformed or unexpected JSON-RPC.');
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('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.');
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
- /** Select only the shipped Screenplay descriptor, never project-supplied commands or environments. */
49
- export function selectedServer(project: string, corpus: string) {
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
- if (!selected.has('cratis/screenplay')) return undefined;
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
- if (descriptor.schemaVersion !== '1.0' || !Array.isArray(descriptor.servers)) throw new Error('Unsupported Cratis MCP descriptor.');
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 { startConnection, supportsNoModelStartup } from './process.ts';
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
- pi.on('session_start', (_event, context) => refresh(context, true));
153
- pi.on('before_agent_start', async (_event, context) => { await refresh(context); });
154
- pi.on('session_shutdown', () => { dispose(); failures.clear(); });
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
+ }
@@ -87,6 +87,30 @@
87
87
  "paths": ["**/*.ts", "**/*.tsx"],
88
88
  "exclude_paths": ["**/node_modules/**", "**/dist/**", "**/wwwroot/**", "**/obj/**", "**/bin/**"],
89
89
  "message": "Cratis Components 4 has no PrimeReact underneath — a `primereact/dialog` import is a leftover from Components 3. Use `CommandDialog` from `@cratis/components/CommandDialog` or `Dialog` from `@cratis/components/Dialogs`."
90
+ },
91
+ {
92
+ "id": "cratis-apex-docs-link",
93
+ "rule": "discoverability.md — canonical links",
94
+ "pattern": "https?://cratis\\.io([/)\"' <]|$)",
95
+ "paths": ["**/*.md", "**/*.mdx", "**/*.html", "**/*.astro"],
96
+ "exclude_paths": ["**/node_modules/**", "**/dist/**", "**/obj/**", "**/bin/**", "**/.ai-work/**"],
97
+ "message": "Discoverability (advisory): the docs host is `https://www.cratis.io`. Link the canonical host, not the bare `cratis.io`."
98
+ },
99
+ {
100
+ "id": "cratis-empty-image-alt",
101
+ "rule": "discoverability.md — images carry alt text",
102
+ "pattern": "!\\[\\]\\(",
103
+ "paths": ["**/*.md", "**/*.mdx"],
104
+ "exclude_paths": ["**/node_modules/**", "**/dist/**", "**/obj/**", "**/bin/**", "**/.ai-work/**"],
105
+ "message": "Discoverability (advisory): an image with empty alt text. Describe the content, or keep it empty only when the image is purely decorative."
106
+ },
107
+ {
108
+ "id": "cratis-package-url-github-only",
109
+ "rule": "discoverability.md — package metadata",
110
+ "pattern": "<PackageProjectUrl>[[:space:]]*https?://github\\.com",
111
+ "paths": ["**/*.csproj", "**/*.props"],
112
+ "exclude_paths": ["**/node_modules/**", "**/obj/**", "**/bin/**"],
113
+ "message": "Discoverability (advisory): `PackageProjectUrl` should point at the product's docs page `https://www.cratis.io/<product>/`, not only at GitHub."
90
114
  }
91
115
  ]
92
116
  }
@@ -0,0 +1,26 @@
1
+ ---
2
+ agent: agent
3
+ description: "Run the discoverability check on content about to be published."
4
+ ---
5
+
6
+ # Pre-publish check
7
+
8
+ Run the discoverability check on what is about to be published: a docs page, blog
9
+ post, landing page, README or package metadata. Follow
10
+ `.cratis/ai/rules/discoverability.md` and invoke **cratis-discoverability**.
11
+
12
+ ## Identify the content
13
+
14
+ Find the files in the change, the content type of each, and the owning source (never
15
+ a synced copy). Ask only when the content type is unclear.
16
+
17
+ ## Check and fix
18
+
19
+ Apply the skill's checks for each content type to the draft: opening definition,
20
+ title and description, canonical names, canonical `https://www.cratis.io/...` links,
21
+ alt text and, for sites and packages, the structured data and metadata. Make the
22
+ fixes in the owning files. Where a fix would hurt readability, keep the text and
23
+ report it. For a built site, run the skill's checker on the build output.
24
+
25
+ Report what changed, what was left alone and why, and any check that could not run.
26
+ Publishing, merging and releasing stay with a person.
@@ -0,0 +1,33 @@
1
+ ---
2
+ applyTo: "**/Documentation/**/*.md,**/Documentation/**/*.mdx,**/src/pages/**/*.astro,**/src/pages/**/*.md,**/src/pages/**/*.mdx,**/src/pages/**/*.html,**/src/content/**/*.md,**/src/content/**/*.mdx"
3
+ paths:
4
+ - "**/Documentation/**/*.md"
5
+ - "**/Documentation/**/*.mdx"
6
+ - "**/src/pages/**/*.astro"
7
+ - "**/src/pages/**/*.md"
8
+ - "**/src/pages/**/*.mdx"
9
+ - "**/src/pages/**/*.html"
10
+ - "**/src/content/**/*.md"
11
+ - "**/src/content/**/*.mdx"
12
+ ---
13
+
14
+ # Writing content that people and assistants can find
15
+
16
+ Readability comes first. If a change for search engines or AI assistants makes a page harder to read, drop the change. Voice, structure and examples stay with [writing-cratis-docs.md](./writing-cratis-docs.md), [editing-cratis-docs.md](./editing-cratis-docs.md) and [documentation-structure-and-formatting.md](./documentation-structure-and-formatting.md); this rule adds only what they do not cover. Before publishing a page, post or package, use **cratis-discoverability**.
17
+
18
+ ## Every page, post and README
19
+
20
+ - **Define first, where a definition belongs.** Landing pages, product index pages, glossary entries, READMEs and package descriptions open with one self-contained sentence: "X is a <category> for <audience> that <does>."
21
+ - **Docs tutorials, getting-started pages and explanations follow [writing-cratis-docs.md](./writing-cratis-docs.md)**: scenario first, pain before relief. Their answer-first, self-contained definition goes in the frontmatter `description`, which serves search and AI. Where it reads naturally, one definition sentence may follow the opening scenario. writing-cratis-docs wins on voice and structure.
22
+ - **Title and description are written by hand.** Title with the distinguishing term first: 50-60 characters for blog and landing pages, 20-60 for docs and reference pages; a README H1 that is the product name is fine. Description is one sentence of 60-160 characters, never over 160, and not a copy of the title or of another page's description.
23
+ - **Name things one way.** The first mention is the full name ("Cratis Chronicle"), later mentions may shorten it. Use the canonical names in the **cratis-discoverability** entity reference, and do not call the platform "Cratis.io".
24
+ - **Links name their destination.** Link text says what is behind it. Outside the docs site, link docs with the absolute canonical host `https://www.cratis.io/...`, never the bare `cratis.io`.
25
+ - **Images carry alt text.** Describe the content. Use an empty `alt=""` only for decoration.
26
+ - **No keyword stuffing, no filler for length, no text hidden from readers.** A page is done when it is complete.
27
+
28
+ ## Sites and packages
29
+
30
+ - Public pages need a canonical link, one `<h1>`, Open Graph tags, valid JSON-LD that describes visible content, a sitemap entry and a `robots.txt` that allows crawling. Add no new `HowTo` or `FAQPage` markup; keep the visible Q&A.
31
+ - `llms.txt` is an optional index. Never claim it improves ranking. See **cratis-llm-friendly-documentation**.
32
+ - Package metadata (`Description`, `PackageProjectUrl`, `PackageTags`, npm `description`, `homepage`, `keywords`) and the README opener use the same definition sentence, and the project URL points at the docs page `https://www.cratis.io/<product>/`.
33
+ - State only facts the repository supports: versions, licenses, limits. The claim gate in `BRAND.md`, `MESSAGING.md` and `SITE.md` still applies.
@@ -131,6 +131,7 @@ A claim is only as good as the signal behind it — a build result, a test run,
131
131
  | C# / TypeScript style and code-quality principles | `csharp.md`, `typescript.md`, `code-quality.md`, `code-quality.csharp.md`, `code-quality.typescript.md` |
132
132
  | Kotlin / Java / Elixir style; Orleans grains; reactors; GitHub Actions workflows | `kotlin.md`, `java.md`, `elixir.md`, `orleans.md`, `reactors.md`, `github-actions.md` |
133
133
  | Documentation pages (`Documentation/`): structure, voice, examples, editing | `documentation.md`, `documentation-structure-and-formatting.md`, `writing-cratis-docs.md`, `editing-cratis-docs.md`, `writing-correct-examples.md` |
134
+ | Findability of published pages, READMEs and package metadata | `discoverability.md` |
134
135
  | Go style, source and module files | `go.md`; framework contributors also read `go-engineering.md`; product APIs use `cratis-chronicle-client-go`, `cratis-arc-go` and `cratis-fundamentals-concept-go` |
135
136
  | Service lifetimes — why anything taking a scoped dependency is scoped or transient, never a singleton | `csharp.md` |
136
137
  | React + Arc + Cratis Components + MVVM + dialogs | `react.md`, `components.md`, `dialogs.md` |
@@ -0,0 +1,2 @@
1
+ Copyright (c) Cratis. All rights reserved.
2
+ Licensed under the MIT license. See LICENSE file in the project root for full license information.
@@ -0,0 +1,73 @@
1
+ ---
2
+ name: cratis-discoverability
3
+ description: Run the pre-publish check that makes Cratis content easy to find in search and AI answers without making it harder to read - a docs page, blog post, landing page, README or package metadata (title, description, opening definition, canonical links, entity names, alt text, structured data). Use when about to publish or review content for discoverability, SEO, AEO or GEO, or when writing package descriptions and README openers. Not for page voice and structure (cratis-documentation-writing), llms.txt design (cratis-llm-friendly-documentation), distribution plans (cratis-developer-marketing) or length decisions (cratis-content-length).
4
+ license: MIT
5
+ ---
6
+
7
+ # Discoverability before you publish
8
+
9
+ Discoverability is a property of good content: a clear definition, an honest title, one name per
10
+ thing and working links. The always-on rule is [discoverability.md](../../rules/discoverability.md);
11
+ this skill is the procedure to run on the finished draft. If a fix makes the page worse to read,
12
+ drop the fix and say so.
13
+
14
+ ## Procedure
15
+
16
+ 1. Name the content type: docs page, blog post, landing page, README or package metadata.
17
+ 2. Name the reader's one job and, for docs, the page type (tutorial, how-to, reference, explanation).
18
+ 3. Apply the checks for that type below. Fix the draft; do not annotate it.
19
+ 4. For a built site, run the checker and fix errors; warnings are judgment calls to report.
20
+ 5. Report what was changed, what was left alone and why.
21
+
22
+ Read `references/page-checklist.md` for the full per-type checklist and the field lengths.
23
+
24
+ ## Checks by content type
25
+
26
+ - **Docs page.** The `description` is written by hand as one self-contained
27
+ definition or outcome sentence. Tutorials, getting-started pages and explanations follow
28
+ `writing-cratis-docs` (scenario first); the definition lives in the `description`, and one
29
+ definition sentence may follow the opening scenario where it reads naturally. `writing-cratis-docs`
30
+ wins on voice and structure. Product index pages open with the definition sentence.
31
+ Reference pages stay terse: title is the exact name plus its product, no question headings, no
32
+ intro. Titles are unique across the site and name the product ("Chronicle projections").
33
+ - **Blog post.** The title states the topic in the words a reader would search. The intro gives the
34
+ conclusion in two sentences. Show the named author and date, add `lastUpdated` only for real
35
+ changes, link the owning docs pages with `https://www.cratis.io/...`, keep `description` apart
36
+ from the card `excerpt`.
37
+ - **Landing page.** One primary term in the title, the `<h1>` or the first paragraph, and a
38
+ one-sentence definition above the fold. Brand lines stay; pair them with a category term in the
39
+ title and description. Respect the claim gate in `BRAND.md`, `MESSAGING.md` and `SITE.md`.
40
+ - **README.** The H1 may be the product name ("Cratis <Product>"). The first sentence defines the product in 15-35 plain
41
+ nouns, with no logo-only or metaphor opener. Links are absolute; link the docs page.
42
+ - **Package metadata.** One sentence naming what the package is and which Cratis product it belongs
43
+ to, identical to the README opener. `PackageProjectUrl` and `homepage` point at the docs page, not
44
+ only at GitHub. Read `references/package-metadata.md` for the field map.
45
+
46
+ ## Names
47
+
48
+ Use the canonical names, one per concept, and the first-mention rule. Read
49
+ `references/entities.md` when naming a product, writing a definition sentence or filling `sameAs`.
50
+ Do not invent a name, URL, maturity word or claim that the references do not state.
51
+
52
+ ## Structured data
53
+
54
+ Describe only what is visible on the page, one `@graph` per page, stable `@id`s. Add no `HowTo` and
55
+ no new `FAQPage`. Read `references/structured-data.md` for the patterns and what each page type
56
+ gets. Do not name people in structured data.
57
+
58
+ ## Checking a built site
59
+
60
+ For a built static site (Astro `dist/` or a plain-HTML repository root), run with Node.js:
61
+ `node <skill-directory>/scripts/verify-discoverability.mjs --root <dir> --site <https://host>`.
62
+ It reports `<html lang>`, title, description, canonical host, `<h1>` count, JSON-LD parse errors,
63
+ `robots.txt`, the sitemap and `llms.txt` structure. Errors exit 1; warnings do not. Pass
64
+ `--warn-only` to downgrade errors and `--require-llms` where the site ships `llms.txt`.
65
+ Read `scripts/verify-discoverability.mjs` only when changing the checker.
66
+
67
+ ## Pitfalls
68
+
69
+ - Question headings everywhere: use them only where they read naturally (concept pages, FAQs,
70
+ comparisons, blog), never on reference pages or tutorial steps.
71
+ - Padding to a word count: there is no minimum length; a complete page is done.
72
+ - Apex `https://cratis.io/` links: the docs host is `https://www.cratis.io`.
73
+ - A description that repeats the title or a sibling page's description.
@@ -0,0 +1,68 @@
1
+ # Entity names and definitions
2
+
3
+ ## Contents
4
+
5
+ - Naming rules
6
+ - Shared identifiers
7
+ - Definition sentences
8
+ - Disambiguation
9
+
10
+ Only facts the Cratis repositories state are listed. If a product, URL or identifier is not here,
11
+ find it in the product's own README or docs before writing it; do not guess.
12
+
13
+ ## Naming rules
14
+
15
+ - First mention on a page: "Cratis Chronicle". Later mentions may be "Chronicle". In package
16
+ descriptions, README H1s and GitHub About text, always the prefixed form.
17
+ - The product is Cratis Direct. "Direct" and "Stagehand" are internal or older names.
18
+ - Cratis Build, Cratis Studio and Cratis Assurance are the three offers on cratis.no. In technical
19
+ copy and metadata, name the product (Chronicle, Arc), not the offer.
20
+ - The product is not "Cratis.io". `cratis.io` is the docs host, canonical `https://www.cratis.io`.
21
+ - "Cratis" alone is both the company and the platform. Say "the Cratis platform" where the company
22
+ could be meant.
23
+ - Cratis Studio maturity: follow `BRAND.md` (Preview) until the owner decides otherwise.
24
+
25
+ ## Shared identifiers
26
+
27
+ | Item | Value |
28
+ | --- | --- |
29
+ | Organization `@id` | `https://cratis.no/#organization` |
30
+ | Company site | `https://cratis.no` |
31
+ | Docs (canonical host) | `https://www.cratis.io` |
32
+ | Blog | `https://blog.cratis.io` |
33
+ | GitHub organization | `https://github.com/Cratis` |
34
+ | NuGet profile | `https://www.nuget.org/profiles/Cratis` |
35
+ | npm organization | `https://www.npmjs.com/org/cratis` |
36
+ | YouTube | `https://www.youtube.com/@CratisStack` |
37
+
38
+ Product docs follow `https://www.cratis.io/<product>/`.
39
+
40
+ ## Definition sentences
41
+
42
+ Use these as the opening sentence of a definitional page, README or package description. Adjust only
43
+ to fit the page; keep the facts.
44
+
45
+ | Name | Sentence | Docs page (under `https://www.cratis.io`) |
46
+ | --- | --- | --- |
47
+ | Cratis (platform) | Cratis is an open-source (MIT) event sourcing and CQRS platform for .NET, built around the Chronicle event-sourcing database. | `https://www.cratis.io/` |
48
+ | Cratis Chronicle | Cratis Chronicle is an open-source (MIT) event-sourcing database and runtime with a .NET kernel and clients for several languages. | `/chronicle/` |
49
+ | Cratis Arc | Cratis Arc is an open-source (MIT) CQRS application framework for ASP.NET Core with generated TypeScript proxies. | `/arc/` |
50
+ | Cratis Fundamentals | Cratis Fundamentals is the set of shared building blocks beneath the Cratis stack for .NET and TypeScript. | `/fundamentals/` |
51
+ | Cratis Studio | Cratis Studio is a collaborative environment for designing, visualizing and editing Screenplay event models. | `/studio/` |
52
+ | Cratis Direct | Cratis Direct is the managed control plane for the Cratis software factory: it turns GitHub signals into prioritized, governed AI work and reviewed pull requests. | none confirmed |
53
+ | Cratis Cast | Cratis Cast is a profile-site engine and directory for the people who build with Cratis, with verified, event-sourced profile pages. | `https://cast.cratis.io` |
54
+ | Cratis Screenplay | Cratis Screenplay is a business-oriented declarative language for specifying the desired functionality of an information system. | `/screenplay/` |
55
+ | Cratis Stage | Cratis Stage compiles Screenplay source, verifies modeled specifications, and renders or partially performs Cratis applications. | none confirmed |
56
+ | Cratis Prologue | Cratis Prologue captures selected signals from an existing system and interprets them into a provisional event model for review. | `/prologue/` |
57
+ | Cratis CLI | The Cratis CLI is a terminal tool for inspecting and diagnosing Cratis Chronicle. | `/cli/` |
58
+ | Chronicle Workbench | Chronicle Workbench is the web dashboard bundled with Cratis Chronicle for browsing events and operating a deployment. | `/chronicle/workbench/` |
59
+ | Chronicle MCP | The Chronicle MCP server connects an AI agent to a running Cratis Chronicle database over the Model Context Protocol. | `/chronicle-mcp/` |
60
+
61
+ Chronicle storage is pluggable (MongoDB by default, PostgreSQL, SQL Server, SQLite, in-memory).
62
+ Arc works without event sourcing; the Chronicle integration is optional.
63
+
64
+ ## Disambiguation
65
+
66
+ - `@cratis/arc` is the browser client of Arc .NET; `@cratis/arc.core` is the Node server package.
67
+ - "Cast" alone is not the streaming protocol: write "Cratis Cast".
68
+ - A docs slug not in the table is unconfirmed. Check the docs site before linking it.
@@ -0,0 +1,38 @@
1
+ # Package metadata
2
+
3
+ ## Contents
4
+
5
+ - Field map
6
+ - Writing the description
7
+ - Pitfalls
8
+
9
+ ## Field map
10
+
11
+ | Field | NuGet (`.csproj`, `Directory.Build.props`) | npm (`package.json`) |
12
+ | --- | --- | --- |
13
+ | Description | `<Description>`, 80-250 characters; the first 100 are what shows | `description`, 80-160 characters |
14
+ | Tags | `<PackageTags>`, 5-10, `cratis` plus category terms | `keywords`, the same list |
15
+ | Project URL | `<PackageProjectUrl>` = `https://www.cratis.io/<product>/` | `homepage`, the same |
16
+ | Repository | `RepositoryUrl` | `repository` |
17
+ | Authors | `Cratis` | `author` `Cratis` |
18
+ | License | `PackageLicenseExpression` `MIT` | `license` `MIT` |
19
+ | Readme | A packed README with the same opener | `README.md` |
20
+
21
+ Use a `https://www.cratis.io/<product>/` page that exists; check the docs site rather than guessing
22
+ a slug. If none exists, leave the existing URL and report it.
23
+
24
+ ## Writing the description
25
+
26
+ One sentence: what it is, for whom, and which Cratis product it belongs to - "<what it is> for
27
+ <audience>, part of Cratis <Parent>". Name the parent so the package disambiguates ("the client for
28
+ Cratis Chronicle, the open-source event-sourcing database"). The text equals the README opener and
29
+ the GitHub About text. Tags are specific ("event-sourcing", "cqrs", "dotnet"); never "utilities".
30
+
31
+ ## Pitfalls
32
+
33
+ - An empty `Description`.
34
+ - Mixed `Authors` values across packages of one product.
35
+ - `PackageProjectUrl` pointing only at GitHub.
36
+ - Two packages with near-identical descriptions. Where `@cratis/arc` (the browser client of Arc
37
+ .NET) and `@cratis/arc.core` (the Node server package) could be confused, each description says
38
+ which one it is.
@@ -0,0 +1,88 @@
1
+ # Page checklist
2
+
3
+ ## Contents
4
+
5
+ - Field targets
6
+ - Every page
7
+ - Docs pages
8
+ - Blog posts
9
+ - Landing pages
10
+ - READMEs
11
+ - Before publishing a site change
12
+
13
+ ## Field targets
14
+
15
+ Lengths are targets, counted in characters including any site suffix.
16
+
17
+ | Field | Target |
18
+ | --- | --- |
19
+ | Title | Distinguishing term first. Blog and landing pages 50-60; docs and reference pages 20-60; a README H1 that is the product name is fine |
20
+ | Description | One sentence, 60-160, never over 160; the point comes first |
21
+ | Lead answer passage | 40-60 words, names its subject, one key property, one limit |
22
+ | Heading | Under about 70 characters |
23
+ | `og:image` | 1200x630, under 200 KB, with `og:image:alt` |
24
+
25
+ ## Every page
26
+
27
+ - [ ] One reader job. For docs, one page type: tutorial, how-to, reference or explanation.
28
+ - [ ] Title unique, distinguishing term near the front.
29
+ - [ ] Description written by hand; not the title, not another page's description.
30
+ - [ ] The `description` answers what the page is or achieves in one self-contained sentence; the first paragraph does too on landing, index, glossary and README pages.
31
+ - [ ] Canonical names; first mention uses the full "Cratis X" form.
32
+ - [ ] Facts, numbers and comparisons carry a source or a date; nothing beyond the claim gate.
33
+ - [ ] Links have descriptive text. Root-relative inside the docs site, absolute
34
+ `https://www.cratis.io/...` elsewhere.
35
+ - [ ] Images have alt text (empty only when decorative) and sensible size.
36
+ - [ ] Linked from at least two other pages and from the right hub; no orphan.
37
+ - [ ] Read aloud once: if it sounds written for a crawler, rewrite it.
38
+
39
+ ## Docs pages
40
+
41
+ - Voice and structure follow `writing-cratis-docs`, which wins on any conflict: tutorials,
42
+ getting-started pages and explanations open with a concrete scenario (pain, then relief). Put the
43
+ answer-first definition in the frontmatter `description`; one definition sentence may follow the
44
+ scenario where it reads naturally.
45
+ - Product index page: opens with the one-sentence definition, then the without-versus-with framing.
46
+ - Concept page: scenario first; then why it exists, when to use it, when not to, how it works.
47
+ H2s such as "What is X" and "When to use X" only where they fit. No H1 in the body.
48
+ - Tutorial: title names the outcome and starts with a verb; the scenario, then what the reader will
49
+ have running; prerequisites and versions up front; one visible result per step; "Last updated" and the
50
+ version it applies to.
51
+ - How-to: the task as the title; goal, prerequisites, numbered steps, a done check, the two likely
52
+ failures; versions stated.
53
+ - Reference: the title is the exact name with its product; first sentence says what it is for;
54
+ tables and signatures; stable anchors; no intro, no pitch. Merge or enrich near-empty stub pages.
55
+
56
+ ## Blog posts
57
+
58
+ - [ ] Title states the topic; series name goes in a `series` field or at the end.
59
+ - [ ] Two-sentence conclusion up front; "when this is the wrong fit" where it applies.
60
+ - [ ] Named author where agreed (`cratis-team` only for company announcements), date, `lastUpdated`
61
+ only on real changes.
62
+ - [ ] At least one internal link to the owning docs page; tags reused from the existing list, no
63
+ singleton tags; a cover image with alt text for posts likely to be shared.
64
+
65
+ ## Landing pages
66
+
67
+ - [ ] One primary term in title, `<h1>` or first paragraph; one-sentence definition above the fold.
68
+ - [ ] Description differs per page and is not a 300-character paragraph.
69
+ - [ ] No testimonials, numbers, customers, prices or maturity claims the claim gate does not allow.
70
+ - [ ] Norwegian pages are written natively, keep established English technical terms, link to the
71
+ English page with `hreflang`, and are not machine-translated and published unreviewed.
72
+
73
+ ## READMEs
74
+
75
+ - [ ] H1 may be the product name ("Cratis <Product>"); first sentence is the definition (15-35 words, no metaphor).
76
+ - [ ] Then install, a minimal working example, a link to `https://www.cratis.io/<product>/`,
77
+ license and how it relates to the rest of Cratis.
78
+ - [ ] GitHub About text equals the README opener (80-160 characters); website is the docs page;
79
+ topics include `cratis` plus category terms.
80
+
81
+ ## Before publishing a site change
82
+
83
+ - [ ] Title, description, canonical, Open Graph and JSON-LD present and unique on changed pages.
84
+ - [ ] Sitemap lists new pages; `robots.txt` unchanged unless intended.
85
+ - [ ] JSON-LD validates; no `HowTo`; no new `FAQPage`.
86
+ - [ ] The content is in the raw HTML the server returns, not only rendered by JavaScript.
87
+ - [ ] Internal links resolve; no apex `cratis.io` links.
88
+ - [ ] A real HTTP 404 for unknown URLs; redirect stubs keep a canonical and stay out of the sitemap.
@@ -0,0 +1,62 @@
1
+ # Structured data patterns
2
+
3
+ ## Contents
4
+
5
+ - Rules
6
+ - Types per page type
7
+ - Pattern
8
+
9
+ ## Rules
10
+
11
+ - One `@graph` per page with stable `@id` values. The organization `@id` is
12
+ `https://cratis.no/#organization`.
13
+ - Markup describes what is visible on the page, and every date is a real one.
14
+ - Add no `HowTo`. Add no new `FAQPage`; keep the visible question and answer text. Use `QAPage` only
15
+ for real user questions and answers.
16
+ - Do not name people (`Person`, `author` name) in structured data unless the owner has approved it.
17
+ Reference the organization by `@id`.
18
+ - Do not add `offers`, an address or contact data without approval (see `SITE.md`).
19
+ - Validate with the schema.org validator or Google's Rich Results Test. Invalid markup is worse than
20
+ none. Static sites hand-write `<script type="application/ld+json">`; Astro sites generate it in
21
+ the head component, so change it there rather than per page.
22
+
23
+ ## Types per page type
24
+
25
+ | Page type | Types |
26
+ | --- | --- |
27
+ | Company home | `Organization` (name, url, logo, `sameAs`), `WebSite` |
28
+ | Product marketing page | `SoftwareApplication` (name, applicationCategory, license, url, `sameAs`) |
29
+ | Docs concept, tutorial, how-to, reference | `TechArticle` with `headline`, `dateModified`, plus `BreadcrumbList` |
30
+ | Docs hub and landing page | `CollectionPage` or `WebPage`, not `TechArticle` |
31
+ | Blog post | `BlogPosting` (`datePublished`, `dateModified`, `publisher`, `image`) |
32
+ | Repository or sample code | `SoftwareSourceCode` |
33
+ | Legal pages | `WebPage` |
34
+ | 404 and app hosts | none |
35
+
36
+ ## Pattern
37
+
38
+ ```json
39
+ {
40
+ "@context": "https://schema.org",
41
+ "@graph": [
42
+ {
43
+ "@type": "TechArticle",
44
+ "@id": "https://www.cratis.io/chronicle/projections/#article",
45
+ "headline": "Chronicle projections",
46
+ "description": "A projection declares how Chronicle builds a read model from events.",
47
+ "dateModified": "YYYY-MM-DD",
48
+ "publisher": { "@id": "https://cratis.no/#organization" },
49
+ "mainEntityOfPage": "https://www.cratis.io/chronicle/projections/"
50
+ },
51
+ {
52
+ "@type": "BreadcrumbList",
53
+ "itemListElement": [
54
+ { "@type": "ListItem", "position": 1, "name": "Chronicle", "item": "https://www.cratis.io/chronicle/" },
55
+ { "@type": "ListItem", "position": 2, "name": "Projections" }
56
+ ]
57
+ }
58
+ ]
59
+ }
60
+ ```
61
+
62
+ The values are illustrative: take the title, description and dates from the page itself.
@@ -0,0 +1,107 @@
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
+ // Checks a built static site (Astro dist/ or a plain-HTML repo root) before it is published:
5
+ // every indexable page has <html lang>, one <title>, a meta description, a canonical on the
6
+ // expected host and parseable JSON-LD; the site has robots.txt, a sitemap and (optionally) a
7
+ // well-formed llms.txt. Node standard library only.
8
+ //
9
+ // Usage: node verify-discoverability.mjs --root dist --site https://blog.cratis.io [--require-llms] [--warn-only]
10
+ // Exit codes: 0 clean or warnings only, 1 errors, 2 could not run.
11
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
12
+ import { join, relative } from 'node:path';
13
+
14
+ const args = process.argv.slice(2);
15
+ const option = (name, fallback) => { const i = args.indexOf(name); return i < 0 ? fallback : args[i + 1]; };
16
+ const root = option('--root', 'dist');
17
+ const site = option('--site');
18
+ const requireLlms = args.includes('--require-llms');
19
+ const warnOnly = args.includes('--warn-only');
20
+ const ignore = (option('--ignore', 'node_modules,.git,.cratis,.github,404.html,_astro,storybook') ?? '').split(',');
21
+
22
+ if (!site || !existsSync(root)) {
23
+ console.error(`could not run: need --site and an existing --root (got ${root})`);
24
+ process.exit(2);
25
+ }
26
+ const host = new URL(site).host;
27
+ let errors = 0;
28
+ let warnings = 0;
29
+ const report = (level, file, message) => {
30
+ if (level === 'error' && !warnOnly) errors++; else warnings++;
31
+ console.log(`::${level === 'error' && !warnOnly ? 'error' : 'warning'} file=${file}::${message}`);
32
+ };
33
+
34
+ function* htmlFiles(dir) {
35
+ for (const name of readdirSync(dir)) {
36
+ if (ignore.includes(name)) continue;
37
+ const path = join(dir, name);
38
+ if (statSync(path).isDirectory()) yield* htmlFiles(path);
39
+ else if (name.endsWith('.html')) yield path;
40
+ }
41
+ }
42
+
43
+ const attr = (tag, name) => tag.match(new RegExp(`${name}\\s*=\\s*["']([^"']*)["']`, 'i'))?.[1];
44
+ const tags = (html, pattern) => [...html.matchAll(pattern)].map(match => match[0]);
45
+ const titles = new Map();
46
+
47
+ for (const path of htmlFiles(root)) {
48
+ const file = relative(process.cwd(), path);
49
+ const html = readFileSync(path, 'utf8');
50
+ const metas = tags(html, /<meta\b[^>]*>/gi);
51
+ const robots = metas.find(tag => /name\s*=\s*["']robots["']/i.test(tag));
52
+ if (robots && /noindex/i.test(attr(robots, 'content') ?? '')) continue; // deliberately not indexed
53
+ if (/http-equiv\s*=\s*["']refresh["']/i.test(html)) continue; // redirect stub
54
+
55
+ if (!/<html\b[^>]*\blang\s*=/i.test(html)) report('error', file, 'missing <html lang>');
56
+ const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim();
57
+ if (!title) report('error', file, 'missing or empty <title>');
58
+ else {
59
+ if (title.length > 65) report('warning', file, `title is ${title.length} characters; keep the distinguishing words in the first 60`);
60
+ titles.set(title, [...(titles.get(title) ?? []), file]);
61
+ }
62
+ const description = metas.find(tag => /name\s*=\s*["']description["']/i.test(tag));
63
+ const descriptionText = description ? attr(description, 'content') ?? '' : '';
64
+ if (!descriptionText) report('error', file, 'missing meta description');
65
+ else if (descriptionText.length < 60 || descriptionText.length > 160) report('warning', file, `meta description is ${descriptionText.length} characters (aim for 60-160)`);
66
+ const canonical = tags(html, /<link\b[^>]*>/gi).find(tag => /rel\s*=\s*["']canonical["']/i.test(tag));
67
+ const href = canonical ? attr(canonical, 'href') : undefined;
68
+ if (!href) report('error', file, 'missing <link rel="canonical">');
69
+ else {
70
+ try {
71
+ const url = new URL(href);
72
+ if (url.host !== host) report('error', file, `canonical points at ${url.host}, expected ${host}`);
73
+ } catch {
74
+ report('error', file, `canonical is not an absolute URL: ${href}`);
75
+ }
76
+ }
77
+ if ((html.match(/<h1\b/gi) ?? []).length !== 1) report('warning', file, 'expected exactly one <h1>');
78
+ for (const block of html.matchAll(/<script\b[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
79
+ try {
80
+ const data = JSON.parse(block[1]);
81
+ const nodes = Array.isArray(data) ? data : data['@graph'] ?? [data];
82
+ if (!Array.isArray(data) && !String(data['@context'] ?? '').includes('schema.org')) report('error', file, 'JSON-LD without a schema.org @context');
83
+ for (const node of nodes) if (!node['@type']) report('warning', file, 'JSON-LD node without @type');
84
+ } catch (error) {
85
+ report('error', file, `JSON-LD does not parse: ${error.message}`);
86
+ }
87
+ }
88
+ }
89
+
90
+ for (const [title, files] of titles) if (files.length > 1) report('warning', files[0], `title "${title}" is shared by ${files.length} pages`);
91
+
92
+ const robotsPath = join(root, 'robots.txt');
93
+ if (!existsSync(robotsPath)) report('error', root, 'no robots.txt');
94
+ else if (!/^\s*sitemap\s*:/im.test(readFileSync(robotsPath, 'utf8'))) report('warning', robotsPath, 'robots.txt does not declare a Sitemap');
95
+ if (!readdirSync(root).some(name => /^sitemap.*\.xml$/.test(name))) report('error', root, 'no sitemap*.xml at the site root');
96
+
97
+ const llmsPath = join(root, 'llms.txt');
98
+ if (existsSync(llmsPath)) {
99
+ // Same structure rules as vendor/claude-seo/scripts/agentic_check.py evaluate_llms_txt (Lighthouse llms-txt audit).
100
+ const llms = readFileSync(llmsPath, 'utf8').replace(/^\uFEFF/, '');
101
+ if (!/^\s*#\s+.+/m.test(llms)) report('error', llmsPath, 'llms.txt has no H1');
102
+ if (!/\[.+\]\(.+\)/.test(llms)) report('error', llmsPath, 'llms.txt has no Markdown links');
103
+ if (!/^\s*>\s+\S/m.test(llms)) report('warning', llmsPath, "llms.txt has no '>' summary");
104
+ } else if (requireLlms) report('error', root, 'no llms.txt');
105
+
106
+ console.log(`verify-discoverability: ${errors} error(s), ${warnings} warning(s)`);
107
+ process.exit(errors ? 1 : 0);
@@ -0,0 +1,13 @@
1
+ {
2
+ "skill": "cratis-discoverability",
3
+ "input": "Run the pre-publish discoverability check on a new blog post, a package README and the package metadata for a Cratis library.",
4
+ "fixtures": [],
5
+ "assertions": [
6
+ { "kind": "skill-contains", "value": "If a fix makes the page worse to read," },
7
+ { "kind": "skill-contains", "value": "Add no `HowTo` and" },
8
+ { "kind": "skill-contains", "value": "Do not name people in structured data." },
9
+ { "kind": "skill-contains", "value": "Apex `https://cratis.io/` links: the docs host is `https://www.cratis.io`." },
10
+ { "kind": "skill-contains", "value": "no logo-only or metaphor opener" },
11
+ { "kind": "skill-contains", "value": "Padding to a word count: there is no minimum length" }
12
+ ]
13
+ }
@@ -657,6 +657,7 @@
657
657
  "availableTargets": [
658
658
  "cratis-content-length",
659
659
  "cratis-developer-marketing",
660
+ "cratis-discoverability",
660
661
  "cratis-release-notes",
661
662
  "cratis-social-feed-post",
662
663
  "cratis-technical-examples",
@@ -670,6 +671,7 @@
670
671
  {
671
672
  "id": "cratis/documentation",
672
673
  "availableTargets": [
674
+ "cratis-discoverability",
673
675
  "cratis-documentation-writing",
674
676
  "cratis-engineering-docs-authoring",
675
677
  "cratis-llm-friendly-documentation",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/pi",
3
- "version": "7.93.0",
3
+ "version": "7.95.0",
4
4
  "description": "Configuration-aware Cratis AI integration for Pi",
5
5
  "type": "module",
6
6
  "files": [