@cratis/pi 7.1.4 → 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -3
- package/package/corpus/harnesses/pi/extensions/cratis-path-guidance/LoadedSkill.ts +9 -0
- package/package/corpus/harnesses/pi/extensions/cratis-path-guidance/SkillMatch.ts +10 -0
- package/package/corpus/harnesses/pi/extensions/cratis-path-guidance/SkillTrigger.ts +13 -0
- package/package/corpus/harnesses/pi/extensions/cratis-path-guidance/ToolName.ts +11 -0
- package/package/corpus/harnesses/pi/extensions/cratis-path-guidance/index.ts +166 -0
- package/package/corpus/harnesses/pi/extensions/cratis-path-guidance/skillReads.ts +62 -0
- package/package/corpus/harnesses/pi/extensions/cratis-path-guidance/skills.ts +72 -0
- package/package/corpus/harnesses/pi/extensions/cratis-path-guidance/standDown.ts +48 -0
- package/package/corpus/harnesses/pi/extensions/cratis-path-guidance/touchedPaths.ts +52 -0
- package/package/corpus/harnesses/pi/extensions/cratis-rules/index.ts +6 -237
- package/package/corpus/harnesses/pi/extensions/shared/AiConfiguration.ts +8 -0
- package/package/corpus/harnesses/pi/extensions/shared/ManagedRule.ts +13 -0
- package/package/corpus/harnesses/pi/extensions/shared/corpusRoot.ts +12 -0
- package/package/corpus/harnesses/pi/extensions/shared/frontmatter.ts +31 -0
- package/package/corpus/harnesses/pi/extensions/shared/globs.ts +62 -0
- package/package/corpus/harnesses/pi/extensions/shared/rules.ts +98 -0
- package/package/corpus/harnesses/pi/extensions/shared/skillFrontmatter.ts +9 -0
- package/package/corpus/harnesses/pi/extensions/shared/skillSelection.ts +67 -0
- package/package/corpus/skills/cratis-application-react-specifications/SKILL.md +3 -0
- package/package/corpus/skills/cratis-application-slice-specifications/SKILL.md +3 -0
- package/package/corpus/skills/cratis-documentation-writing/SKILL.md +18 -3
- package/package/corpus/skills/cratis-documentation-writing/references/cratis-site.md +14 -0
- package/package/corpus/skills/cratis-documentation-writing/verification.json +5 -1
- package/package/corpus/skills/cratis-engineering-docs-authoring/SKILL.md +2 -0
- package/package/corpus/skills/cratis-specifications-csharp/SKILL.md +2 -0
- package/package/corpus/skills/cratis-specifications-typescript/SKILL.md +3 -0
- package/package/corpus/skills/cratis-technical-examples/SKILL.md +15 -0
- package/package/corpus/skills/cratis-technical-examples/verification.json +4 -1
- package/package.json +2 -1
- package/src/index.ts +11 -0
|
@@ -1,250 +1,19 @@
|
|
|
1
1
|
// Copyright (c) Cratis. All rights reserved.
|
|
2
2
|
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
3
|
|
|
4
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
5
|
-
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
6
|
-
import { fileURLToPath } from 'node:url';
|
|
7
4
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
8
|
-
|
|
9
|
-
const corpusRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
|
|
10
|
-
|
|
11
|
-
type AiConfiguration = { profiles?: string[] };
|
|
12
|
-
|
|
13
|
-
export interface ManagedRule {
|
|
14
|
-
/** Path relative to the rules root, e.g. `code-quality-csharp.md` or `project/running-the-local-stack.md`. */
|
|
15
|
-
name: string;
|
|
16
|
-
/** Full file content, frontmatter included, exactly as the other harnesses receive it. */
|
|
17
|
-
content: string;
|
|
18
|
-
/** `application`, `framework`, or undefined when the rule is not profile-specific. */
|
|
19
|
-
profile?: string;
|
|
20
|
-
/** Globs from `applyTo` and `paths`. Empty means the rule applies to every file. */
|
|
21
|
-
globs: string[];
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const universalGlobs = new Set(['**', '**/*', '*']);
|
|
25
|
-
|
|
26
|
-
function unquote(value: string): string {
|
|
27
|
-
return value.trim().replace(/^["']|["']$/g, '');
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Reads the YAML-ish frontmatter the corpus uses: scalar `key: value` lines and `key:` followed by
|
|
32
|
-
* ` - item` lines. Anything richer is not used by the rules and is deliberately not supported.
|
|
33
|
-
*/
|
|
34
|
-
function frontmatter(content: string): Map<string, string[]> {
|
|
35
|
-
const fields = new Map<string, string[]>();
|
|
36
|
-
if (!content.startsWith('---\n')) return fields;
|
|
37
|
-
const end = content.indexOf('\n---\n', 4);
|
|
38
|
-
if (end < 0) return fields;
|
|
39
|
-
let current: string | undefined;
|
|
40
|
-
for (const line of content.slice(4, end).split('\n')) {
|
|
41
|
-
const item = /^\s+-\s+(.*)$/.exec(line);
|
|
42
|
-
if (item && current) {
|
|
43
|
-
fields.get(current)!.push(unquote(item[1]));
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
const scalar = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line);
|
|
47
|
-
if (!scalar) continue;
|
|
48
|
-
current = scalar[1];
|
|
49
|
-
const value = unquote(scalar[2]);
|
|
50
|
-
fields.set(current, value ? value.split(',').map(unquote).filter(Boolean) : []);
|
|
51
|
-
}
|
|
52
|
-
return fields;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function escapeRegExp(text: string): string {
|
|
56
|
-
return text.replace(/[.+^$()|[\]\\]/g, '\\$&');
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Converts the glob dialect used by `applyTo` (`**`, `*`, `?`, `{a,b}`) into an anchored RegExp. */
|
|
60
|
-
export function globToRegExp(glob: string): RegExp {
|
|
61
|
-
let pattern = '';
|
|
62
|
-
for (let index = 0; index < glob.length; index++) {
|
|
63
|
-
const character = glob[index];
|
|
64
|
-
if (character === '*') {
|
|
65
|
-
if (glob[index + 1] === '*') {
|
|
66
|
-
if (glob[index + 2] === '/') {
|
|
67
|
-
pattern += '(?:.*/)?';
|
|
68
|
-
index += 2;
|
|
69
|
-
} else {
|
|
70
|
-
pattern += '.*';
|
|
71
|
-
index += 1;
|
|
72
|
-
}
|
|
73
|
-
} else {
|
|
74
|
-
pattern += '[^/]*';
|
|
75
|
-
}
|
|
76
|
-
} else if (character === '?') {
|
|
77
|
-
pattern += '[^/]';
|
|
78
|
-
} else if (character === '{') {
|
|
79
|
-
const close = glob.indexOf('}', index);
|
|
80
|
-
if (close > index) {
|
|
81
|
-
pattern += `(?:${glob.slice(index + 1, close).split(',').map(part => escapeRegExp(part.trim())).join('|')})`;
|
|
82
|
-
index = close;
|
|
83
|
-
} else {
|
|
84
|
-
pattern += '\\{';
|
|
85
|
-
}
|
|
86
|
-
} else {
|
|
87
|
-
pattern += escapeRegExp(character);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return new RegExp(`^${pattern}$`);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function rulesRoot(cwd: string): string {
|
|
94
|
-
const managedRoot = join(cwd, '.cratis', 'ai', 'rules');
|
|
95
|
-
return existsSync(managedRoot) ? managedRoot : join(corpusRoot, 'rules');
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function configuration(cwd: string): AiConfiguration | undefined {
|
|
99
|
-
const path = join(cwd, '.cratis', 'ai.json');
|
|
100
|
-
if (!existsSync(path)) return undefined;
|
|
101
|
-
try {
|
|
102
|
-
return JSON.parse(readFileSync(path, 'utf8')) as AiConfiguration;
|
|
103
|
-
} catch {
|
|
104
|
-
return undefined;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* A profile-specific rule is kept only when the repository selects that profile. Repositories without
|
|
110
|
-
* a `.cratis/ai.json` keep every rule, matching the `@cratis/pi` package.
|
|
111
|
-
*/
|
|
112
|
-
function matchesProfile(rule: ManagedRule, selected: AiConfiguration | undefined): boolean {
|
|
113
|
-
if (!rule.profile || !selected) return true;
|
|
114
|
-
const profiles = selected.profiles ?? [];
|
|
115
|
-
if (rule.profile === 'application') return profiles.some(profile => profile.startsWith('cratis/application'));
|
|
116
|
-
if (rule.profile === 'framework') return profiles.some(profile => profile.startsWith('cratis/engineering'));
|
|
117
|
-
return true;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** Loads every managed rule with its frontmatter interpreted, filtered to the repository's profiles. */
|
|
121
|
-
export function managedRules(cwd: string): ManagedRule[] {
|
|
122
|
-
const root = rulesRoot(cwd);
|
|
123
|
-
const selected = configuration(cwd);
|
|
124
|
-
return readdirSync(root, { recursive: true, encoding: 'utf8' })
|
|
125
|
-
.filter((entry): entry is string => entry.endsWith('.md'))
|
|
126
|
-
.sort()
|
|
127
|
-
.map(entry => {
|
|
128
|
-
const content = readFileSync(join(root, entry), 'utf8');
|
|
129
|
-
const fields = frontmatter(content);
|
|
130
|
-
return {
|
|
131
|
-
name: entry.split(sep).join('/'),
|
|
132
|
-
content,
|
|
133
|
-
profile: fields.get('profile')?.[0],
|
|
134
|
-
globs: [...(fields.get('applyTo') ?? []), ...(fields.get('paths') ?? [])],
|
|
135
|
-
} satisfies ManagedRule;
|
|
136
|
-
})
|
|
137
|
-
.filter(rule => matchesProfile(rule, selected));
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Rules that apply to every file. These belong in the system prompt. */
|
|
141
|
-
export function universalRules(cwd: string): ManagedRule[] {
|
|
142
|
-
return managedRules(cwd).filter(rule => rule.globs.length === 0 || rule.globs.some(glob => universalGlobs.has(glob)));
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Rules whose `applyTo`/`paths` match a repository-relative path. These are delivered when that file is touched. */
|
|
146
|
-
export function rulesForPath(cwd: string, relativePath: string): ManagedRule[] {
|
|
147
|
-
const normalized = relativePath.split(sep).join('/');
|
|
148
|
-
return managedRules(cwd).filter(rule =>
|
|
149
|
-
rule.globs.length > 0 &&
|
|
150
|
-
!rule.globs.some(glob => universalGlobs.has(glob)) &&
|
|
151
|
-
rule.globs.some(glob => globToRegExp(glob).test(normalized)));
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Upper bound on files considered from one bash command, so a wide command cannot deliver the corpus. */
|
|
155
|
-
const maxPathsPerCommand = 10;
|
|
5
|
+
import { universalRules } from '../shared/rules.ts';
|
|
156
6
|
|
|
157
7
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* receive a path-scoped rule. A token counts only when it resolves to a file that exists inside the
|
|
162
|
-
* working directory, which keeps a filename mentioned inside a commit message from matching.
|
|
163
|
-
*/
|
|
164
|
-
function pathsFromCommand(command: string, cwd: string): string[] {
|
|
165
|
-
const found: string[] = [];
|
|
166
|
-
for (const raw of command.split(/[\s;|&()<>]+/)) {
|
|
167
|
-
if (found.length >= maxPathsPerCommand) break;
|
|
168
|
-
const token = raw.replace(/^['"]+|['"]+$/g, '').replace(/[,:]+$/, '');
|
|
169
|
-
if (!token || token.startsWith('-') || !/[./]/.test(token)) continue;
|
|
170
|
-
try {
|
|
171
|
-
if (statSync(resolve(cwd, token)).isFile()) found.push(token);
|
|
172
|
-
} catch {
|
|
173
|
-
/* not a path we can see; ignore */
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return found;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function touchedPaths(toolName: string, input: unknown, cwd: string): string[] {
|
|
180
|
-
if (toolName === 'bash' || toolName === 'powershell') {
|
|
181
|
-
const command = (input as { command?: unknown } | undefined)?.command;
|
|
182
|
-
return typeof command === 'string' ? pathsFromCommand(command, cwd) : [];
|
|
183
|
-
}
|
|
184
|
-
if (toolName !== 'read' && toolName !== 'write' && toolName !== 'edit') return [];
|
|
185
|
-
const candidate = (input as { path?: unknown; file_path?: unknown } | undefined);
|
|
186
|
-
const value = candidate?.path ?? candidate?.file_path;
|
|
187
|
-
return typeof value === 'string' && value.length > 0 ? [value] : [];
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Gives Pi the same rule semantics as the other harnesses: universal rules in the system prompt, and
|
|
192
|
-
* path-scoped rules attached the first time a matching file is touched in the session. Without this,
|
|
193
|
-
* every rule was concatenated into every turn regardless of `applyTo`, `paths`, or `profile`.
|
|
194
|
-
*
|
|
195
|
-
* Delivery happens on `tool_result`, so a rule arrives after the call that first touched its file.
|
|
196
|
-
* `ToolCallEventResult` carries only `block`/`reason`/`terminate`, so there is no supported way to add
|
|
197
|
-
* context before a tool runs. In practice a file is read before it is edited, and reads through both
|
|
198
|
-
* the read tool and bash are covered, so the rule is present before the edit; a file created blind by
|
|
199
|
-
* `write` is the residual case, and it receives the rule with that result.
|
|
8
|
+
* Puts the universal rules in the system prompt, giving Pi the same rule semantics as the other harnesses.
|
|
9
|
+
* Rules scoped by `applyTo`, `paths` or `profile` are not concatenated into every turn; the
|
|
10
|
+
* `cratis-path-guidance` extension attaches path-scoped rules the first time a matching file is touched.
|
|
200
11
|
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* happens, and the rule is delivered again the next time one of its files is touched.
|
|
12
|
+
* The universal rules are a large, fixed cost on every turn (about 26k tokens), which is why a session that
|
|
13
|
+
* only wants path guidance and the hooks should load `cratis-path-guidance` and `cratis-hooks` instead.
|
|
204
14
|
*/
|
|
205
15
|
export default function (pi: ExtensionAPI): void {
|
|
206
|
-
const delivered = new Set<string>();
|
|
207
|
-
|
|
208
|
-
// Compaction summarizes the conversation, which can drop an injected rule while leaving the
|
|
209
|
-
// delivery record claiming it is present; a switch replaces the conversation outright. Without
|
|
210
|
-
// this reset a long session would silently lose its scoped rules and never see them again.
|
|
211
|
-
const reset = () => {
|
|
212
|
-
delivered.clear();
|
|
213
|
-
};
|
|
214
|
-
pi.on('session_start', reset);
|
|
215
|
-
pi.on('session_compact', reset);
|
|
216
|
-
pi.on('session_before_switch', reset);
|
|
217
|
-
|
|
218
16
|
pi.on('before_agent_start', (event, context) => ({
|
|
219
17
|
systemPrompt: `${event.systemPrompt}\n\n${universalRules(context.cwd).map(rule => rule.content).join('\n\n')}`,
|
|
220
18
|
}));
|
|
221
|
-
|
|
222
|
-
pi.on('tool_result', (event, context) => {
|
|
223
|
-
if (event.isError) return;
|
|
224
|
-
const paths = touchedPaths(event.toolName, (event as { input?: unknown }).input, context.cwd);
|
|
225
|
-
if (paths.length === 0) return;
|
|
226
|
-
const pending: ManagedRule[] = [];
|
|
227
|
-
const matched: string[] = [];
|
|
228
|
-
for (const path of paths) {
|
|
229
|
-
const relativePath = relative(context.cwd, resolve(context.cwd, path));
|
|
230
|
-
if (!relativePath || relativePath.startsWith('..')) continue;
|
|
231
|
-
for (const rule of rulesForPath(context.cwd, relativePath)) {
|
|
232
|
-
if (delivered.has(rule.name) || pending.some(candidate => candidate.name === rule.name)) continue;
|
|
233
|
-
pending.push(rule);
|
|
234
|
-
if (!matched.includes(relativePath)) matched.push(relativePath);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
if (pending.length === 0) return;
|
|
238
|
-
pending.forEach(rule => delivered.add(rule.name));
|
|
239
|
-
const existing = Array.isArray(event.content) ? event.content : [];
|
|
240
|
-
return {
|
|
241
|
-
content: [
|
|
242
|
-
...existing,
|
|
243
|
-
{
|
|
244
|
-
type: 'text',
|
|
245
|
-
text: `\n\n[cratis-rules] Rules that apply to ${matched.map(path => path.split(sep).join('/')).join(', ')}:\n\n${pending.map(rule => rule.content).join('\n\n')}`,
|
|
246
|
-
},
|
|
247
|
-
],
|
|
248
|
-
};
|
|
249
|
-
});
|
|
250
19
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
/** The parts of a repository's `.cratis/ai.json` that rule and skill selection read. */
|
|
5
|
+
export interface AiConfiguration {
|
|
6
|
+
profiles?: string[];
|
|
7
|
+
languages?: string[];
|
|
8
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
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 ManagedRule {
|
|
5
|
+
/** Path relative to the rules root, e.g. `code-quality-csharp.md` or `project/running-the-local-stack.md`. */
|
|
6
|
+
name: string;
|
|
7
|
+
/** Full file content, frontmatter included, exactly as the other harnesses receive it. */
|
|
8
|
+
content: string;
|
|
9
|
+
/** `application`, `framework`, or undefined when the rule is not profile-specific. */
|
|
10
|
+
profile?: string;
|
|
11
|
+
/** Globs from `applyTo` and `paths`. Empty means the rule applies to every file. */
|
|
12
|
+
globs: string[];
|
|
13
|
+
}
|
|
@@ -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 { dirname, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The corpus this extension tree ships in: `.cratis/ai` for a managed installation and the repository,
|
|
9
|
+
* `package/corpus` inside the published `@cratis/pi` package. Both place `harnesses/pi/extensions/shared`
|
|
10
|
+
* four levels below it.
|
|
11
|
+
*/
|
|
12
|
+
export const corpusRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
|
|
@@ -0,0 +1,31 @@
|
|
|
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 function unquote(value: string): string {
|
|
5
|
+
return value.trim().replace(/^["']|["']$/g, '');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reads the YAML-ish frontmatter the corpus uses: scalar `key: value` lines and `key:` followed by
|
|
10
|
+
* ` - item` lines. Anything richer is not used by the rules and skills and is deliberately not supported.
|
|
11
|
+
*/
|
|
12
|
+
export function frontmatter(content: string): Map<string, string[]> {
|
|
13
|
+
const fields = new Map<string, string[]>();
|
|
14
|
+
if (!content.startsWith('---\n')) return fields;
|
|
15
|
+
const end = content.indexOf('\n---\n', 4);
|
|
16
|
+
if (end < 0) return fields;
|
|
17
|
+
let current: string | undefined;
|
|
18
|
+
for (const line of content.slice(4, end).split('\n')) {
|
|
19
|
+
const item = /^\s+-\s+(.*)$/.exec(line);
|
|
20
|
+
if (item && current) {
|
|
21
|
+
fields.get(current)!.push(unquote(item[1]));
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const scalar = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line);
|
|
25
|
+
if (!scalar) continue;
|
|
26
|
+
current = scalar[1];
|
|
27
|
+
const value = unquote(scalar[2]);
|
|
28
|
+
fields.set(current, value ? value.split(',').map(unquote).filter(Boolean) : []);
|
|
29
|
+
}
|
|
30
|
+
return fields;
|
|
31
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
/** Globs that match every file. A rule or skill that declares one is not scoped to a path. */
|
|
5
|
+
export const universalGlobs = new Set(['**', '**/*', '*']);
|
|
6
|
+
|
|
7
|
+
function escapeRegExp(text: string): string {
|
|
8
|
+
return text.replace(/[.+^$()|[\]\\]/g, '\\$&');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Converts the glob dialect used by `applyTo` and `paths` (`**`, `*`, `?`, `{a,b}`) into an anchored RegExp. */
|
|
12
|
+
export function globToRegExp(glob: string): RegExp {
|
|
13
|
+
let pattern = '';
|
|
14
|
+
for (let index = 0; index < glob.length; index++) {
|
|
15
|
+
const character = glob[index];
|
|
16
|
+
if (character === '*') {
|
|
17
|
+
if (glob[index + 1] === '*') {
|
|
18
|
+
if (glob[index + 2] === '/') {
|
|
19
|
+
pattern += '(?:.*/)?';
|
|
20
|
+
index += 2;
|
|
21
|
+
} else {
|
|
22
|
+
pattern += '.*';
|
|
23
|
+
index += 1;
|
|
24
|
+
}
|
|
25
|
+
} else {
|
|
26
|
+
pattern += '[^/]*';
|
|
27
|
+
}
|
|
28
|
+
} else if (character === '?') {
|
|
29
|
+
pattern += '[^/]';
|
|
30
|
+
} else if (character === '{') {
|
|
31
|
+
const close = glob.indexOf('}', index);
|
|
32
|
+
if (close > index) {
|
|
33
|
+
pattern += `(?:${glob.slice(index + 1, close).split(',').map(part => escapeRegExp(part.trim())).join('|')})`;
|
|
34
|
+
index = close;
|
|
35
|
+
} else {
|
|
36
|
+
pattern += '\\{';
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
pattern += escapeRegExp(character);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return new RegExp(`^${pattern}$`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Why a glob cannot scope a skill trigger, or undefined when it can. A trigger must be a non-empty,
|
|
47
|
+
* repository-relative, balanced glob that does not match every file, otherwise it would either never fire
|
|
48
|
+
* or fire on every write.
|
|
49
|
+
*/
|
|
50
|
+
export function globProblem(glob: string): string | undefined {
|
|
51
|
+
if (glob.trim().length === 0) return 'is empty';
|
|
52
|
+
if (glob !== glob.trim()) return 'has leading or trailing whitespace';
|
|
53
|
+
if (glob.startsWith('/') || glob.includes('\\')) return 'must be a repository-relative glob using forward slashes';
|
|
54
|
+
if (universalGlobs.has(glob)) return 'matches every file';
|
|
55
|
+
if (glob.split('{').length !== glob.split('}').length) return 'has unbalanced braces';
|
|
56
|
+
try {
|
|
57
|
+
globToRegExp(glob);
|
|
58
|
+
} catch {
|
|
59
|
+
return 'does not compile to a matcher';
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
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, readFileSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { join, sep } from 'node:path';
|
|
6
|
+
import type { AiConfiguration } from './AiConfiguration.ts';
|
|
7
|
+
import { corpusRoot } from './corpusRoot.ts';
|
|
8
|
+
import { frontmatter } from './frontmatter.ts';
|
|
9
|
+
import { globToRegExp, universalGlobs } from './globs.ts';
|
|
10
|
+
import type { ManagedRule } from './ManagedRule.ts';
|
|
11
|
+
|
|
12
|
+
function rulesRoot(cwd: string): string {
|
|
13
|
+
const managedRoot = join(cwd, '.cratis', 'ai', 'rules');
|
|
14
|
+
return existsSync(managedRoot) ? managedRoot : join(corpusRoot, 'rules');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function configuration(cwd: string): AiConfiguration | undefined {
|
|
18
|
+
const path = join(cwd, '.cratis', 'ai.json');
|
|
19
|
+
if (!existsSync(path)) return undefined;
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(readFileSync(path, 'utf8')) as AiConfiguration;
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A profile-specific rule is kept only when the repository selects that profile. Repositories without
|
|
29
|
+
* a `.cratis/ai.json` keep every rule, matching the `@cratis/pi` package.
|
|
30
|
+
*/
|
|
31
|
+
function matchesProfile(rule: ManagedRule, selected: AiConfiguration | undefined): boolean {
|
|
32
|
+
if (!rule.profile || !selected) return true;
|
|
33
|
+
const profiles = selected.profiles ?? [];
|
|
34
|
+
if (rule.profile === 'application') return profiles.some(profile => profile.startsWith('cratis/application'));
|
|
35
|
+
if (rule.profile === 'framework') return profiles.some(profile => profile.startsWith('cratis/engineering'));
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The packaged `@cratis/pi` corpus holds every rule, so the languages and documentation a repository selected
|
|
41
|
+
* decide which apply: a rule scoped to `.cs` files needs `csharp`, to `.ts` files needs `typescript`, and to
|
|
42
|
+
* Markdown needs the `cratis/documentation` profile. A repository that selects no languages keeps them all.
|
|
43
|
+
*/
|
|
44
|
+
function matchesSelection(name: string, applyTo: string[], selected: AiConfiguration | undefined): boolean {
|
|
45
|
+
const languages = selected?.languages ?? [];
|
|
46
|
+
if (!selected || languages.length === 0) return true;
|
|
47
|
+
const scope = applyTo.join(',');
|
|
48
|
+
const needsCSharp = scope.includes('.cs');
|
|
49
|
+
const needsTypeScript = scope.includes('.ts') || name === 'rtk.md' || name.endsWith('/rtk.md');
|
|
50
|
+
const needsDocumentation = scope.includes('md');
|
|
51
|
+
if (!needsCSharp && !needsTypeScript && !needsDocumentation) return true;
|
|
52
|
+
return (needsCSharp && languages.includes('csharp')) ||
|
|
53
|
+
(needsTypeScript && languages.includes('typescript')) ||
|
|
54
|
+
(needsDocumentation && (selected.profiles ?? []).includes('cratis/documentation'));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Loads every managed rule with its frontmatter interpreted, filtered to the repository's profiles. From the
|
|
59
|
+
* packaged corpus, which is not resolved for the repository, the selected languages and documentation apply as
|
|
60
|
+
* well. A managed `.cratis/ai/rules` is already resolved by the CLI, so that second filter is skipped there.
|
|
61
|
+
*/
|
|
62
|
+
export function managedRules(cwd: string): ManagedRule[] {
|
|
63
|
+
const root = rulesRoot(cwd);
|
|
64
|
+
const selected = configuration(cwd);
|
|
65
|
+
const resolved = existsSync(join(cwd, '.cratis', 'ai', 'rules'));
|
|
66
|
+
return readdirSync(root, { recursive: true, encoding: 'utf8' })
|
|
67
|
+
.filter((entry): entry is string => entry.endsWith('.md'))
|
|
68
|
+
.sort()
|
|
69
|
+
.map(entry => {
|
|
70
|
+
const content = readFileSync(join(root, entry), 'utf8');
|
|
71
|
+
const fields = frontmatter(content);
|
|
72
|
+
return {
|
|
73
|
+
rule: {
|
|
74
|
+
name: entry.split(sep).join('/'),
|
|
75
|
+
content,
|
|
76
|
+
profile: fields.get('profile')?.[0],
|
|
77
|
+
globs: [...(fields.get('applyTo') ?? []), ...(fields.get('paths') ?? [])],
|
|
78
|
+
} satisfies ManagedRule,
|
|
79
|
+
applyTo: fields.get('applyTo') ?? [],
|
|
80
|
+
};
|
|
81
|
+
})
|
|
82
|
+
.filter(({ rule, applyTo }) => matchesProfile(rule, selected) && (resolved || matchesSelection(rule.name, applyTo, selected)))
|
|
83
|
+
.map(({ rule }) => rule);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Rules that apply to every file. These belong in the system prompt. */
|
|
87
|
+
export function universalRules(cwd: string): ManagedRule[] {
|
|
88
|
+
return managedRules(cwd).filter(rule => rule.globs.length === 0 || rule.globs.some(glob => universalGlobs.has(glob)));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Rules whose `applyTo`/`paths` match a repository-relative path. These are delivered when that file is touched. */
|
|
92
|
+
export function rulesForPath(cwd: string, relativePath: string): ManagedRule[] {
|
|
93
|
+
const normalized = relativePath.split(sep).join('/');
|
|
94
|
+
return managedRules(cwd).filter(rule =>
|
|
95
|
+
rule.globs.length > 0 &&
|
|
96
|
+
!rule.globs.some(glob => universalGlobs.has(glob)) &&
|
|
97
|
+
rule.globs.some(glob => globToRegExp(glob).test(normalized)));
|
|
98
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
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
|
+
/**
|
|
5
|
+
* The `SKILL.md` frontmatter key that lists the globs a skill is hinted for. It is deliberately Cratis-specific:
|
|
6
|
+
* Claude Code gives a plain `paths` key in `SKILL.md` a meaning of its own (a "conditional skill" that stays out
|
|
7
|
+
* of the skill list until a matching file is touched), and `.claude/skills` exposes this corpus to it.
|
|
8
|
+
*/
|
|
9
|
+
export const skillTriggerKey = 'cratis-hint-paths';
|
|
@@ -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 { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import type { AiConfiguration } from './AiConfiguration.ts';
|
|
7
|
+
import { corpusRoot } from './corpusRoot.ts';
|
|
8
|
+
|
|
9
|
+
interface Profile {
|
|
10
|
+
id: string;
|
|
11
|
+
composes?: string[];
|
|
12
|
+
availableTargets?: string[];
|
|
13
|
+
languages?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Catalog {
|
|
17
|
+
publicProfiles: Profile[];
|
|
18
|
+
engineeringProfiles: Profile[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The catalog beside the repository corpus (`.cratis/ai`), or beside `package/corpus` in the published package. */
|
|
22
|
+
function catalog(): Profile[] | undefined {
|
|
23
|
+
const path = [join(corpusRoot, 'profile-catalog.json'), join(corpusRoot, '..', 'profile-catalog.json')].find(existsSync);
|
|
24
|
+
if (path === undefined) return undefined;
|
|
25
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as Catalog;
|
|
26
|
+
return [...parsed.publicProfiles, ...parsed.engineeringProfiles];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function supportsLanguage(profile: Profile, languages: Set<string>): boolean {
|
|
30
|
+
if (!profile.languages?.length) return true;
|
|
31
|
+
return profile.languages.some(language => language === 'language-agnostic' || languages.has(language));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The skills the packaged `@cratis/pi` makes available for a repository, resolved from `.cratis/ai.json` and the
|
|
36
|
+
* profile catalog exactly as `selectedSkillPaths` in `@cratis/pi` does (a repository without `ai.json` gets every
|
|
37
|
+
* catalog skill, because that is what the package loads for it). Undefined when the selection cannot be resolved,
|
|
38
|
+
* so a caller hints nothing rather than guessing.
|
|
39
|
+
*
|
|
40
|
+
* This mirrors `Source/Pi.Plugin/src/index.ts`, which cannot import from the corpus in both the repository and
|
|
41
|
+
* the published layout; a specification keeps the two in step.
|
|
42
|
+
*/
|
|
43
|
+
export function selectedSkillNames(cwd: string): string[] | undefined {
|
|
44
|
+
try {
|
|
45
|
+
const profiles = catalog();
|
|
46
|
+
if (profiles === undefined) return undefined;
|
|
47
|
+
const configurationPath = join(cwd, '.cratis', 'ai.json');
|
|
48
|
+
if (!existsSync(configurationPath)) {
|
|
49
|
+
return [...new Set(profiles.flatMap(profile => profile.availableTargets ?? []))].sort();
|
|
50
|
+
}
|
|
51
|
+
const configuration = JSON.parse(readFileSync(configurationPath, 'utf8')) as AiConfiguration;
|
|
52
|
+
const languages = new Set(configuration.languages ?? []);
|
|
53
|
+
const selected = new Set<string>();
|
|
54
|
+
const select = (id: string, explicitSelection: boolean): void => {
|
|
55
|
+
const profile = profiles.find(candidate => candidate.id === id);
|
|
56
|
+
if (!profile) throw new Error(`Unknown Cratis AI profile '${id}'.`);
|
|
57
|
+
if (!explicitSelection && !supportsLanguage(profile, languages)) return;
|
|
58
|
+
if (selected.has(id)) return;
|
|
59
|
+
selected.add(id);
|
|
60
|
+
profile.composes?.forEach(child => select(child, false));
|
|
61
|
+
};
|
|
62
|
+
configuration.profiles?.forEach(profile => select(profile, true));
|
|
63
|
+
return [...new Set(profiles.filter(profile => selected.has(profile.id)).flatMap(profile => profile.availableTargets ?? []))].sort();
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
name: cratis-application-react-specifications
|
|
3
3
|
description: Write specifications for the React and TypeScript surface of a Cratis application slice — view models, helpers, command orchestration, and narrow component behavior — using Vitest with Mocha-style describe/it, Sinon, and the Chai should interface. Use when adding or changing frontend behavior in an application that consumes Cratis. Do not use for backend scenarios or for specifications inside a Cratis framework package.
|
|
4
4
|
license: MIT
|
|
5
|
+
cratis-hint-paths:
|
|
6
|
+
- "**/for_*/**/*.ts"
|
|
7
|
+
- "**/for_*/**/*.tsx"
|
|
5
8
|
---
|
|
6
9
|
|
|
7
10
|
# Cratis application React specifications
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
name: cratis-application-slice-specifications
|
|
3
3
|
description: Specify the backend behavior of an event-sourced Cratis application slice with the in-process scenario family — CommandScenario, EventScenario, ReadModelScenario and ReactorScenario — including what to cover for each slice type and where the specification files live. Use when adding or changing backend behavior in an application built on Cratis. Do not use for framework library specifications and do not use for frontend behavior.
|
|
4
4
|
license: MIT
|
|
5
|
+
cratis-hint-paths:
|
|
6
|
+
- "**/when_*/**/*.cs"
|
|
7
|
+
- "**/for_*/**/*.cs"
|
|
5
8
|
---
|
|
6
9
|
|
|
7
10
|
# Cratis application slice specifications
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
name: cratis-documentation-writing
|
|
3
3
|
description: Plan, write, and improve user-centered Cratis documentation with a clear reader journey and one primary Diátaxis purpose per page. Use for product docs, tutorials, how-to guides, reference, explanations, and documentation reviews. For executable examples use cratis-technical-examples; for release notes use cratis-release-notes. Do not invent APIs or publish content.
|
|
4
4
|
license: MIT
|
|
5
|
+
cratis-hint-paths:
|
|
6
|
+
- "**/Documentation/**/*.{md,mdx}"
|
|
5
7
|
---
|
|
6
8
|
|
|
7
9
|
# Documentation writing
|
|
@@ -61,7 +63,13 @@ and neighboring pages; ask only when the alternatives change the outcome.
|
|
|
61
63
|
targeted recipes, and exact reference. Give different languages or hosts
|
|
62
64
|
their own procedures when a shared path cannot be run as written. A
|
|
63
65
|
'coming from X' bridge should map familiar concepts to the new workflow,
|
|
64
|
-
not replace it.
|
|
66
|
+
not replace it. When a page shows several backends, state how they differ
|
|
67
|
+
once, in a "how the backends differ" table near the start: identity
|
|
68
|
+
source, id and response wire types, default authorization, live versus
|
|
69
|
+
snapshot queries, and unsupported features with their tracking issue.
|
|
70
|
+
Inside a step, write only what the current tab's reader needs. Across a
|
|
71
|
+
series, keep backend limitations in one table on the series index and link
|
|
72
|
+
it; don't repeat an issue-tracked limitation on every page.
|
|
65
73
|
4. Draft in workflow order. For a tutorial, use one working domain throughout,
|
|
66
74
|
show what to run and what appears, and recap before adding another concept.
|
|
67
75
|
For a how-to, keep only what the specific task needs. Link out for details.
|
|
@@ -144,6 +152,12 @@ Never transcribe an API from memory, hand-translate an unsupported client, or
|
|
|
144
152
|
claim a pasted block is runnable when it requires unstated setup. Show the
|
|
145
153
|
command and observable output for a substantial walkthrough.
|
|
146
154
|
|
|
155
|
+
Adding a backend or client to a product also changes pages the product's own
|
|
156
|
+
checks don't scan, such as cross-product and site-level pages. Search them for
|
|
157
|
+
single-language claims ("in C#", C#-only APIs presented as universal) and for
|
|
158
|
+
series indexes whose description, reading order or production claims no longer
|
|
159
|
+
hold.
|
|
160
|
+
|
|
147
161
|
Edit the *authored* file, never a synced copy. The Cratis site derives each
|
|
148
162
|
product page's edit link from that source path, so don't hand-author
|
|
149
163
|
`editUrl` in product frontmatter. Recheck examples against the supported
|
|
@@ -157,8 +171,9 @@ wording, structure and examples as readers hit them.
|
|
|
157
171
|
|
|
158
172
|
Read [Cratis site specifics](references/cratis-site.md) before writing a
|
|
159
173
|
Cratis product page. It covers the teaching components (`YouWillLearn`,
|
|
160
|
-
`Recap`, client tabs
|
|
161
|
-
|
|
174
|
+
`Recap`, client tabs, including on site-owned pages), the variant-docs audit,
|
|
175
|
+
maturity and preview labeling, cross-product compatibility, the Prompter
|
|
176
|
+
feedback signal, and who decides page structure.
|
|
162
177
|
|
|
163
178
|
## Contextual awareness
|
|
164
179
|
|