@cratis/pi 7.1.5 → 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 +2 -0
- 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 +2 -0
- package/package.json +2 -1
- package/src/index.ts +11 -0
package/README.md
CHANGED
|
@@ -9,8 +9,8 @@ pi install -l npm:@cratis/pi
|
|
|
9
9
|
|
|
10
10
|
The package reads the repository's `.cratis/ai.json`, resolves its profiles and
|
|
11
11
|
languages through the packaged profile catalog, and contributes only the matching
|
|
12
|
-
skills. It also loads packaged rules, prompts, agents, the
|
|
13
|
-
Cratis quality hooks. When no `.cratis/ai.json` exists, it exposes the complete
|
|
12
|
+
skills. It also loads packaged rules, path guidance, prompts, agents, the
|
|
13
|
+
subagent tool, and Cratis quality hooks. When no `.cratis/ai.json` exists, it exposes the complete
|
|
14
14
|
packaged skill set. It therefore gives Pi the complete single-harness Cratis
|
|
15
15
|
experience without requiring the Cratis CLI.
|
|
16
16
|
|
|
@@ -24,7 +24,48 @@ The managed CLI path remains the choice when one repository must configure and
|
|
|
24
24
|
synchronize several harnesses. It writes the resolved corpus to `.cratis/ai`,
|
|
25
25
|
creates every harness integration, and records hashes for safe update and
|
|
26
26
|
uninstall. That managed setup loads rules through its own `.pi/extensions/cratis-rules`
|
|
27
|
-
|
|
27
|
+
and `.pi/extensions/cratis-path-guidance` extensions and does not require this package. If both paths are present, the package
|
|
28
28
|
yields to the managed installation to avoid duplicate resources and hooks. The Pi
|
|
29
29
|
package manages only Pi; it neither configures other harnesses nor owns a local
|
|
30
30
|
managed corpus.
|
|
31
|
+
|
|
32
|
+
## Path guidance and subagents
|
|
33
|
+
|
|
34
|
+
The packaged `cratis-path-guidance` extension attaches a path-scoped rule to the
|
|
35
|
+
tool result the first time a matching file is touched, and after a successful
|
|
36
|
+
`write` or `edit` adds one advisory line naming every skill whose `SKILL.md`
|
|
37
|
+
`cratis-hint-paths` frontmatter matches the file. (The key is Cratis-specific
|
|
38
|
+
because Claude Code gives a plain `paths` key its own meaning.) A skill is hinted at
|
|
39
|
+
most once per session and not when it is already in context: read (a shell command
|
|
40
|
+
counts only when `cat`, `sed`, `head`, `tail`, `less`, `bat`, `rg` or `grep` opens
|
|
41
|
+
its `SKILL.md` or a file under `references/`), preloaded by pi-subagents, or
|
|
42
|
+
expanded by `/skill:name`. A pi-subagents header without the skill's text (its
|
|
43
|
+
loader cannot read a symlinked `.pi/skills`) does not count as preloaded. The
|
|
44
|
+
skills considered are the ones Pi loaded plus the skills the repository selected
|
|
45
|
+
(`.cratis/ai.json`), so a session whose Pi skill list holds only personal skills,
|
|
46
|
+
or none (for example `skills: false`), still gets hints; an unselected skill is
|
|
47
|
+
never hinted, and none is added if the selection cannot be resolved. Rules follow
|
|
48
|
+
the same language and documentation selection as the system prompt. Nothing is
|
|
49
|
+
blocked and the system prompt is untouched. The system prompt carries only the
|
|
50
|
+
universal rules, so path-scoped rules are not delivered twice. The extension works
|
|
51
|
+
in sessions without a UI. It stands down when a managed installation
|
|
52
|
+
(`.cratis/ai.manifest.json`) already delivers path guidance: through its own
|
|
53
|
+
`.pi/extensions/cratis-path-guidance`, or through any `.pi/extensions/cratis-rules`
|
|
54
|
+
that is not the current universal-only version, since every earlier version
|
|
55
|
+
delivers path-scoped rules itself. A `cratis-rules` that cannot be recognised
|
|
56
|
+
counts as one that delivers them. In such an older managed installation, a subagent
|
|
57
|
+
that loads only the allowlisted extensions below gets no path guidance until
|
|
58
|
+
`cratis ai update` installs the managed `cratis-path-guidance`.
|
|
59
|
+
|
|
60
|
+
The universal rules add about 26k tokens to every session. A pi-subagents agent
|
|
61
|
+
(`@tintinweb/pi-subagents`) with an `extensions:` allowlist loads only the
|
|
62
|
+
extensions it names, so it gets none of this package by default. Name the two
|
|
63
|
+
cheap extensions to give it path guidance and the write and store guards without
|
|
64
|
+
the universal rules:
|
|
65
|
+
|
|
66
|
+
```yaml
|
|
67
|
+
extensions: cratis-path-guidance, cratis-hooks
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The `subagent` tool of this package starts a full `pi` process and loads every
|
|
71
|
+
extension.
|
|
@@ -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
|
+
/** The part of a Pi skill (`event.systemPromptOptions.skills`) this extension reads. */
|
|
5
|
+
export interface LoadedSkill {
|
|
6
|
+
name: string;
|
|
7
|
+
filePath: string;
|
|
8
|
+
baseDir?: string;
|
|
9
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import type { SkillTrigger } from './SkillTrigger.ts';
|
|
5
|
+
|
|
6
|
+
/** A skill whose trigger matched a path, with the glob that matched. */
|
|
7
|
+
export interface SkillMatch {
|
|
8
|
+
skill: SkillTrigger;
|
|
9
|
+
glob: string;
|
|
10
|
+
}
|
|
@@ -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
|
+
/** A skill available to the session together with the path globs its `SKILL.md` declares as triggers. */
|
|
5
|
+
export interface SkillTrigger {
|
|
6
|
+
name: string;
|
|
7
|
+
/** Absolute path of the skill's `SKILL.md`. */
|
|
8
|
+
filePath: string;
|
|
9
|
+
/** Directory holding `SKILL.md` and the skill's references. */
|
|
10
|
+
baseDir: string;
|
|
11
|
+
/** The `cratis-hint-paths` frontmatter of the skill. Empty when the skill declares no trigger. */
|
|
12
|
+
globs: string[];
|
|
13
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
/** The Pi tools whose results carry a file path this extension reacts to. */
|
|
5
|
+
export enum ToolName {
|
|
6
|
+
Read = 'read',
|
|
7
|
+
Write = 'write',
|
|
8
|
+
Edit = 'edit',
|
|
9
|
+
Bash = 'bash',
|
|
10
|
+
PowerShell = 'powershell',
|
|
11
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
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, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
7
|
+
import type { ManagedRule } from '../shared/ManagedRule.ts';
|
|
8
|
+
import { rulesForPath } from '../shared/rules.ts';
|
|
9
|
+
import type { LoadedSkill } from './LoadedSkill.ts';
|
|
10
|
+
import type { SkillMatch } from './SkillMatch.ts';
|
|
11
|
+
import type { SkillTrigger } from './SkillTrigger.ts';
|
|
12
|
+
import { skillsRead } from './skillReads.ts';
|
|
13
|
+
import { skillsForPath, skillTriggers } from './skills.ts';
|
|
14
|
+
import { standsDown } from './standDown.ts';
|
|
15
|
+
import { ToolName } from './ToolName.ts';
|
|
16
|
+
import { touchedPaths } from './touchedPaths.ts';
|
|
17
|
+
|
|
18
|
+
const extensionDirectory = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const isPackagedExtension = extensionDirectory.includes(`${sep}package${sep}corpus${sep}`);
|
|
20
|
+
|
|
21
|
+
function repositoryRelative(cwd: string, path: string): string | undefined {
|
|
22
|
+
const relativePath = relative(cwd, resolve(cwd, path));
|
|
23
|
+
return !relativePath || relativePath.startsWith('..') || isAbsolute(relativePath) ? undefined : relativePath.split(sep).join('/');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function displayPath(cwd: string, path: string): string {
|
|
27
|
+
return repositoryRelative(cwd, path) ?? path;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One line for everything a write matched, however many skills, so overlapping triggers cost one line and not several. */
|
|
31
|
+
function hintLine(cwd: string, files: string[], matches: SkillMatch[]): string {
|
|
32
|
+
const skills = matches.map(match => `\`${match.skill.name}\` (matched \`${match.glob}\`)`);
|
|
33
|
+
const named = skills.length === 1 ? `Skill ${skills[0]} covers` : `Skills ${skills.slice(0, -1).join(', ')} and ${skills[skills.length - 1]} cover`;
|
|
34
|
+
const documents = matches.map(match => displayPath(cwd, match.skill.filePath));
|
|
35
|
+
const read = documents.length === 1 ? documents[0] : `${documents.slice(0, -1).join(', ')} and ${documents[documents.length - 1]}`;
|
|
36
|
+
return `[cratis-path-guidance] ${named} ${files.join(', ')}; read ${read} before continuing.`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* pi-subagents writes `# Preloaded Skill: <name>` for every name in `skills:` and then the skill's text, or a
|
|
41
|
+
* placeholder when it could not load it: `(Skill "<name>" not found in ...)` or `(Skill "<name>" skipped: ...)`.
|
|
42
|
+
* It cannot load a skill from a symlinked skills root, which is how a managed installation exposes
|
|
43
|
+
* `.pi/skills` and `.agents/skills`. Only a header followed by real content puts the skill in context.
|
|
44
|
+
*/
|
|
45
|
+
const placeholderBody = /^\(Skill "[^"]*" (?:not found|skipped)\b/;
|
|
46
|
+
|
|
47
|
+
/** The skills pi-subagents preloaded into the system prompt (`skills: a, b`) whose full text is already in context. */
|
|
48
|
+
function preloadedSkillNames(systemPrompt: unknown): Set<string> {
|
|
49
|
+
const names = new Set<string>();
|
|
50
|
+
if (typeof systemPrompt !== 'string') return names;
|
|
51
|
+
for (const match of systemPrompt.matchAll(/^# Preloaded Skill: (\S+)[ \t]*\r?\n/gm)) {
|
|
52
|
+
const body = systemPrompt.slice(match.index + match[0].length).split(/^# Preloaded Skill: /m)[0].trim();
|
|
53
|
+
if (body.length > 0 && !placeholderBody.test(body)) names.add(match[1]);
|
|
54
|
+
}
|
|
55
|
+
return names;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The skills a `/skill:name` command expanded into the user message, which arrive without a `read` call. */
|
|
59
|
+
function expandedSkillNames(prompt: unknown): string[] {
|
|
60
|
+
return typeof prompt === 'string' ? [...prompt.matchAll(/<skill name="([^"]+)"/g)].map(match => match[1]) : [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Delivers the corpus guidance that belongs to a file at the moment the file is touched, so it works in every
|
|
65
|
+
* Pi process, including a subagent, without the universal rules `cratis-rules` puts in every system prompt.
|
|
66
|
+
*
|
|
67
|
+
* Path-scoped rules are attached to the tool result the first time a matching file is touched in the session,
|
|
68
|
+
* exactly as they were when `cratis-rules` delivered them. On a successful `write` or `edit`, one advisory line
|
|
69
|
+
* names every skill whose `cratis-hint-paths` frontmatter matches the file and where its `SKILL.md` is. A skill
|
|
70
|
+
* is not hinted when it is already in context: read in the session, preloaded by pi-subagents
|
|
71
|
+
* (`# Preloaded Skill: <name>` followed by its text in the system prompt), or expanded by `/skill:<name>`. Hints are advisory only:
|
|
72
|
+
* nothing is blocked and the system prompt is never touched.
|
|
73
|
+
*
|
|
74
|
+
* Delivery happens on `tool_result`, so guidance arrives after the call that first touched the file.
|
|
75
|
+
* `ToolCallEventResult` carries only `block`/`reason`/`terminate`, so there is no supported way to add
|
|
76
|
+
* context before a tool runs. In practice a file is read before it is edited, and reads through both the read
|
|
77
|
+
* tool and bash are covered; a file created blind by `write` is the residual case, and it receives the
|
|
78
|
+
* guidance with that result.
|
|
79
|
+
*
|
|
80
|
+
* Delivered guidance lives in the conversation rather than the system prompt, so anything that rewrites or
|
|
81
|
+
* replaces the conversation can remove it. The delivery record is therefore reset whenever that happens, and the
|
|
82
|
+
* guidance is delivered again the next time one of its files is touched.
|
|
83
|
+
*/
|
|
84
|
+
export default function (pi: ExtensionAPI): void {
|
|
85
|
+
if (standsDown(isPackagedExtension, process.cwd())) return;
|
|
86
|
+
|
|
87
|
+
const deliveredRules = new Set<string>();
|
|
88
|
+
const hintedSkills = new Set<string>();
|
|
89
|
+
const readSkills = new Set<string>();
|
|
90
|
+
// Preloaded skills live in the system prompt, which compaction leaves alone, so this is replaced at each
|
|
91
|
+
// agent start rather than reset with the conversation.
|
|
92
|
+
let preloadedSkills = new Set<string>();
|
|
93
|
+
let loadedSkills: LoadedSkill[] | undefined;
|
|
94
|
+
let loadedKey: string | undefined;
|
|
95
|
+
let triggers: SkillTrigger[] | undefined;
|
|
96
|
+
|
|
97
|
+
const availableTriggers = (cwd: string): SkillTrigger[] => triggers ??= skillTriggers(loadedSkills, cwd);
|
|
98
|
+
|
|
99
|
+
// Compaction summarizes the conversation, which can drop an injected rule or hint while leaving the
|
|
100
|
+
// delivery record claiming it is present; a switch replaces the conversation outright.
|
|
101
|
+
const reset = () => {
|
|
102
|
+
deliveredRules.clear();
|
|
103
|
+
hintedSkills.clear();
|
|
104
|
+
readSkills.clear();
|
|
105
|
+
triggers = undefined;
|
|
106
|
+
};
|
|
107
|
+
pi.on('session_start', reset);
|
|
108
|
+
pi.on('session_compact', reset);
|
|
109
|
+
pi.on('session_before_switch', reset);
|
|
110
|
+
|
|
111
|
+
// Only observes which skills are in context for this session; the system prompt is never changed.
|
|
112
|
+
pi.on('before_agent_start', event => {
|
|
113
|
+
const observed = event as { systemPrompt?: unknown; prompt?: unknown; systemPromptOptions?: { skills?: LoadedSkill[] } };
|
|
114
|
+
preloadedSkills = preloadedSkillNames(observed.systemPrompt);
|
|
115
|
+
expandedSkillNames(observed.prompt).forEach(name => readSkills.add(name));
|
|
116
|
+
const skills = observed.systemPromptOptions?.skills;
|
|
117
|
+
const key = skills?.map(skill => `${skill.name}\t${skill.filePath}`).join('\n');
|
|
118
|
+
if (key !== loadedKey) triggers = undefined;
|
|
119
|
+
loadedSkills = skills;
|
|
120
|
+
loadedKey = key;
|
|
121
|
+
return undefined;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
pi.on('tool_result', (event, context) => {
|
|
125
|
+
if (event.isError) return;
|
|
126
|
+
const cwd = context.cwd;
|
|
127
|
+
const toolName: string = event.toolName;
|
|
128
|
+
const input = event.input;
|
|
129
|
+
|
|
130
|
+
const reads = skillsRead(toolName, input, availableTriggers(cwd));
|
|
131
|
+
reads.forEach(skill => readSkills.add(skill.name));
|
|
132
|
+
|
|
133
|
+
const pendingRules: ManagedRule[] = [];
|
|
134
|
+
const matchedFiles: string[] = [];
|
|
135
|
+
const hintedFiles: string[] = [];
|
|
136
|
+
const pendingMatches: SkillMatch[] = [];
|
|
137
|
+
for (const path of touchedPaths(toolName, input, cwd)) {
|
|
138
|
+
const relativePath = repositoryRelative(cwd, path);
|
|
139
|
+
if (!relativePath) continue;
|
|
140
|
+
for (const rule of rulesForPath(cwd, relativePath)) {
|
|
141
|
+
if (deliveredRules.has(rule.name) || pendingRules.some(candidate => candidate.name === rule.name)) continue;
|
|
142
|
+
pendingRules.push(rule);
|
|
143
|
+
if (!matchedFiles.includes(relativePath)) matchedFiles.push(relativePath);
|
|
144
|
+
}
|
|
145
|
+
if (toolName !== ToolName.Write && toolName !== ToolName.Edit) continue;
|
|
146
|
+
for (const match of skillsForPath(availableTriggers(cwd), relativePath)) {
|
|
147
|
+
const name = match.skill.name;
|
|
148
|
+
if (hintedSkills.has(name) || readSkills.has(name) || preloadedSkills.has(name)) continue;
|
|
149
|
+
hintedSkills.add(name);
|
|
150
|
+
pendingMatches.push(match);
|
|
151
|
+
if (!hintedFiles.includes(relativePath)) hintedFiles.push(relativePath);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (pendingRules.length === 0 && pendingMatches.length === 0) return;
|
|
155
|
+
pendingRules.forEach(rule => deliveredRules.add(rule.name));
|
|
156
|
+
|
|
157
|
+
const existing = Array.isArray(event.content) ? event.content : [];
|
|
158
|
+
const text = [
|
|
159
|
+
pendingRules.length === 0
|
|
160
|
+
? undefined
|
|
161
|
+
: `\n\n[cratis-rules] Rules that apply to ${matchedFiles.join(', ')}:\n\n${pendingRules.map(rule => rule.content).join('\n\n')}`,
|
|
162
|
+
pendingMatches.length === 0 ? undefined : `\n\n${hintLine(cwd, hintedFiles, pendingMatches)}`,
|
|
163
|
+
].filter(part => part !== undefined).join('');
|
|
164
|
+
return { content: [...existing, { type: 'text', text }] };
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -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
|
+
import type { SkillTrigger } from './SkillTrigger.ts';
|
|
5
|
+
import { ToolName } from './ToolName.ts';
|
|
6
|
+
import { commandOf, isShellTool } from './touchedPaths.ts';
|
|
7
|
+
|
|
8
|
+
/** Shell commands that put a file's content in front of the model. `ls`, `git diff` or `git add` do not. */
|
|
9
|
+
const readers = new Set(['cat', 'sed', 'head', 'tail', 'less', 'bat', 'rg', 'grep']);
|
|
10
|
+
|
|
11
|
+
/** `rtk` verbs that read: `rtk read`, `rtk cat`, and `rtk grep`/`rtk rg` on a file. */
|
|
12
|
+
const rtkReaders = new Set(['read', ...readers]);
|
|
13
|
+
|
|
14
|
+
function escapeRegExp(text: string): string {
|
|
15
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Whether a path names a file inside the skill's directory. */
|
|
19
|
+
function isInsideSkill(path: string, skill: SkillTrigger): boolean {
|
|
20
|
+
if (path.includes(`${skill.baseDir}/`)) return true;
|
|
21
|
+
return new RegExp(`(?:^|[/\\s'"=])skills/${escapeRegExp(skill.name)}/`).test(path.replaceAll('\\', '/'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether text names the skill's `SKILL.md`, or a file under its `references/`. */
|
|
25
|
+
function namesSkillDocument(text: string, skill: SkillTrigger): boolean {
|
|
26
|
+
const normalized = text.replaceAll('\\', '/');
|
|
27
|
+
if (normalized.includes(`${skill.baseDir}/SKILL.md`) || normalized.includes(`${skill.baseDir}/references/`)) return true;
|
|
28
|
+
return new RegExp(`(?:^|[/\\s'"=])skills/${escapeRegExp(skill.name)}/(?:SKILL\\.md|references/)`).test(normalized);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The commands of a shell line that read a file. The line is split at `&&`, `||`, `|`, `;` and newlines, and a
|
|
33
|
+
* command counts when its verb (after an optional `rtk` or `rtk proxy`) is a reader. An `echo`, a commit message
|
|
34
|
+
* or `git diff` that merely mentions a path is not a read.
|
|
35
|
+
*/
|
|
36
|
+
function readerCommands(command: string): string[] {
|
|
37
|
+
return command.split(/\s*(?:&&|\|\||\||;(?=\s|$)|\n)\s*/).filter(segment => {
|
|
38
|
+
const words = segment.trim().split(/\s+/);
|
|
39
|
+
if (words[0] !== 'rtk') return readers.has(words[0]);
|
|
40
|
+
const verb = words[1] === 'proxy' ? words[2] : words[1];
|
|
41
|
+
return words[1] === 'proxy' ? readers.has(verb) : rtkReaders.has(verb);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The skills a successful tool call read: the `read` tool on any file under `skills/<name>/`, or a shell reader
|
|
47
|
+
* (`cat`, `sed`, `head`, `tail`, `less`, `bat`, `rg`, `grep`, `rtk read`) on that skill's `SKILL.md` or a file
|
|
48
|
+
* under its `references/`. A model that opened either already has the skill's guidance. Listing the directory,
|
|
49
|
+
* or a `git` command that names the path, gives it nothing and is not a read.
|
|
50
|
+
*/
|
|
51
|
+
export function skillsRead(toolName: string, input: unknown, skills: SkillTrigger[]): SkillTrigger[] {
|
|
52
|
+
if (toolName === ToolName.Read) {
|
|
53
|
+
const candidate = input as { path?: unknown; file_path?: unknown } | undefined;
|
|
54
|
+
const value = candidate?.path ?? candidate?.file_path;
|
|
55
|
+
return typeof value === 'string' ? skills.filter(skill => isInsideSkill(value, skill)) : [];
|
|
56
|
+
}
|
|
57
|
+
if (!isShellTool(toolName)) return [];
|
|
58
|
+
const command = commandOf(input);
|
|
59
|
+
if (command === undefined) return [];
|
|
60
|
+
const commands = readerCommands(command);
|
|
61
|
+
return skills.filter(skill => commands.some(segment => namesSkillDocument(segment, skill)));
|
|
62
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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 { dirname, join } from 'node:path';
|
|
6
|
+
import { corpusRoot } from '../shared/corpusRoot.ts';
|
|
7
|
+
import { frontmatter } from '../shared/frontmatter.ts';
|
|
8
|
+
import { globToRegExp } from '../shared/globs.ts';
|
|
9
|
+
import { skillTriggerKey } from '../shared/skillFrontmatter.ts';
|
|
10
|
+
import { selectedSkillNames } from '../shared/skillSelection.ts';
|
|
11
|
+
import type { LoadedSkill } from './LoadedSkill.ts';
|
|
12
|
+
import type { SkillMatch } from './SkillMatch.ts';
|
|
13
|
+
import type { SkillTrigger } from './SkillTrigger.ts';
|
|
14
|
+
|
|
15
|
+
function skillAt(root: string, name: string): LoadedSkill[] {
|
|
16
|
+
const filePath = join(root, name, 'SKILL.md');
|
|
17
|
+
return existsSync(filePath) ? [{ name, filePath, baseDir: join(root, name) }] : [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The skills the repository selected, whether or not Pi loaded them into the session. A managed installation resolves
|
|
22
|
+
* them into `.cratis/ai/skills`. Without one, the packaged corpus holds every catalog skill, so the selection
|
|
23
|
+
* comes from `.cratis/ai.json` and the profile catalog; when that cannot be resolved nothing is hinted.
|
|
24
|
+
*/
|
|
25
|
+
function repositorySkills(cwd: string): LoadedSkill[] {
|
|
26
|
+
const managedRoot = join(cwd, '.cratis', 'ai', 'skills');
|
|
27
|
+
if (existsSync(managedRoot)) {
|
|
28
|
+
return readdirSync(managedRoot, { withFileTypes: true })
|
|
29
|
+
.filter(entry => entry.isDirectory())
|
|
30
|
+
.flatMap(entry => skillAt(managedRoot, entry.name));
|
|
31
|
+
}
|
|
32
|
+
const packagedRoot = join(corpusRoot, 'skills');
|
|
33
|
+
return (selectedSkillNames(cwd) ?? []).flatMap(name => skillAt(packagedRoot, name));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function triggerGlobs(filePath: string): string[] {
|
|
37
|
+
try {
|
|
38
|
+
return frontmatter(readFileSync(filePath, 'utf8')).get(skillTriggerKey) ?? [];
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The skills available to a session with their trigger globs: the skills Pi loaded for the session
|
|
46
|
+
* (`systemPromptOptions.skills`) together with the repository's selected skills, deduplicated by name with the
|
|
47
|
+
* loaded one winning. The union matters because Pi's list says nothing about whether the Cratis skills are in it:
|
|
48
|
+
* it can be empty (a pi-subagents agent with `skills: false`, the usual setup for cheap workers), or non-empty
|
|
49
|
+
* with only personal skills (an agent with `skills: true` whose `extensions:` allowlist leaves `@cratis/pi`, and
|
|
50
|
+
* so its skill paths, out). The selected skills' `SKILL.md` can still be read by path, which is what the hint asks
|
|
51
|
+
* for; a corpus skill the repository did not select is never added. Skills without a `cratis-hint-paths` trigger
|
|
52
|
+
* are left out.
|
|
53
|
+
*/
|
|
54
|
+
export function skillTriggers(loaded: LoadedSkill[] | undefined, cwd: string): SkillTrigger[] {
|
|
55
|
+
const loadedNames = new Set((loaded ?? []).map(skill => skill.name));
|
|
56
|
+
return [...(loaded ?? []), ...repositorySkills(cwd).filter(skill => !loadedNames.has(skill.name))]
|
|
57
|
+
.map(skill => ({
|
|
58
|
+
name: skill.name,
|
|
59
|
+
filePath: skill.filePath,
|
|
60
|
+
baseDir: skill.baseDir ?? dirname(skill.filePath),
|
|
61
|
+
globs: triggerGlobs(skill.filePath),
|
|
62
|
+
}))
|
|
63
|
+
.filter(skill => skill.globs.length > 0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The skills whose trigger matches a repository-relative path, each with the first glob that matched. */
|
|
67
|
+
export function skillsForPath(triggers: SkillTrigger[], relativePath: string): SkillMatch[] {
|
|
68
|
+
return triggers.flatMap(skill => {
|
|
69
|
+
const glob = skill.globs.find(candidate => globToRegExp(candidate).test(relativePath));
|
|
70
|
+
return glob === undefined ? [] : [{ skill, glob }];
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether a managed `cratis-rules` delivers path-scoped rules itself, which is every generation but the current one:
|
|
9
|
+
*
|
|
10
|
+
* - `6b0bb54`, `5b048c6`: `before_agent_start` concatenates every rule into the system prompt;
|
|
11
|
+
* - `aca9c6b`, `59362cf`, `1937b06`, `eec744a`: universal rules in the system prompt, path-scoped rules on
|
|
12
|
+
* `tool_result`;
|
|
13
|
+
* - current: universal rules only, taken from `../shared/rules.ts`, with no `tool_result` handler.
|
|
14
|
+
*
|
|
15
|
+
* Only a file that is recognisably the current one, importing the shared rules and having no `tool_result`
|
|
16
|
+
* handler, is trusted not to deliver path rules; anything else, including a file that cannot be read, is assumed
|
|
17
|
+
* to. Standing down on a guess costs allowlisted subagents their path guidance until `cratis ai update`, which
|
|
18
|
+
* is cheaper than duplicating every path-scoped rule in every full session.
|
|
19
|
+
*/
|
|
20
|
+
function managedRulesDeliverPaths(cwd: string): boolean {
|
|
21
|
+
const path = join(cwd, '.pi', 'extensions', 'cratis-rules', 'index.ts');
|
|
22
|
+
if (!existsSync(path)) return false;
|
|
23
|
+
try {
|
|
24
|
+
const content = readFileSync(path, 'utf8');
|
|
25
|
+
return !content.includes("'../shared/rules.ts'") || content.includes('tool_result');
|
|
26
|
+
} catch {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A managed installation (`.cratis/ai.manifest.json`) loads its own extensions from `.pi/extensions`. The copy
|
|
33
|
+
* shipped in `@cratis/pi` stands down when that installation already delivers path guidance, so a rule or hint
|
|
34
|
+
* is never delivered twice, whichever versions are mixed:
|
|
35
|
+
*
|
|
36
|
+
* - the managed `cratis-path-guidance` exists: it delivers rules and hints;
|
|
37
|
+
* - an older managed `cratis-rules` exists, which delivers path-scoped rules itself, in the system prompt or on
|
|
38
|
+
* `tool_result`: the packaged copy would repeat every rule, so it yields until `cratis ai update` installs the
|
|
39
|
+
* managed copy;
|
|
40
|
+
* - a current managed `cratis-rules` that only injects universal rules, or no managed Pi extensions at all:
|
|
41
|
+
* nothing else delivers path guidance, so the packaged copy stays active.
|
|
42
|
+
*
|
|
43
|
+
* The managed copy never stands down.
|
|
44
|
+
*/
|
|
45
|
+
export function standsDown(packaged: boolean, cwd: string): boolean {
|
|
46
|
+
if (!packaged || !existsSync(join(cwd, '.cratis', 'ai.manifest.json'))) return false;
|
|
47
|
+
return existsSync(join(cwd, '.pi', 'extensions', 'cratis-path-guidance', 'index.ts')) || managedRulesDeliverPaths(cwd);
|
|
48
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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 { statSync } from 'node:fs';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { ToolName } from './ToolName.ts';
|
|
7
|
+
|
|
8
|
+
/** Upper bound on files considered from one bash command, so a wide command cannot deliver the corpus. */
|
|
9
|
+
const maxPathsPerCommand = 10;
|
|
10
|
+
|
|
11
|
+
export function isShellTool(toolName: string): boolean {
|
|
12
|
+
return toolName === ToolName.Bash || toolName === ToolName.PowerShell;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function commandOf(input: unknown): string | undefined {
|
|
16
|
+
const command = (input as { command?: unknown } | undefined)?.command;
|
|
17
|
+
return typeof command === 'string' ? command : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extracts file paths a bash command refers to. `rtk.md` tells agents to run `rtk read`, `rtk grep`
|
|
22
|
+
* and `rtk find` from the terminal for bulk reads, so file access frequently arrives as a bash
|
|
23
|
+
* command rather than the read tool; without this, a session that follows that rule would never
|
|
24
|
+
* receive a path-scoped rule. A token counts only when it resolves to a file that exists inside the
|
|
25
|
+
* working directory, which keeps a filename mentioned inside a commit message from matching.
|
|
26
|
+
*/
|
|
27
|
+
function pathsFromCommand(command: string, cwd: string): string[] {
|
|
28
|
+
const found: string[] = [];
|
|
29
|
+
for (const raw of command.split(/[\s;|&()<>]+/)) {
|
|
30
|
+
if (found.length >= maxPathsPerCommand) break;
|
|
31
|
+
const token = raw.replace(/^['"]+|['"]+$/g, '').replace(/[,:]+$/, '');
|
|
32
|
+
if (!token || token.startsWith('-') || !/[./]/.test(token)) continue;
|
|
33
|
+
try {
|
|
34
|
+
if (statSync(resolve(cwd, token)).isFile()) found.push(token);
|
|
35
|
+
} catch {
|
|
36
|
+
/* not a path we can see; ignore */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return found;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The file paths a tool call refers to, as the tool received them. */
|
|
43
|
+
export function touchedPaths(toolName: string, input: unknown, cwd: string): string[] {
|
|
44
|
+
if (isShellTool(toolName)) {
|
|
45
|
+
const command = commandOf(input);
|
|
46
|
+
return command === undefined ? [] : pathsFromCommand(command, cwd);
|
|
47
|
+
}
|
|
48
|
+
if (toolName !== ToolName.Read && toolName !== ToolName.Write && toolName !== ToolName.Edit) return [];
|
|
49
|
+
const candidate = (input as { path?: unknown; file_path?: unknown } | undefined);
|
|
50
|
+
const value = candidate?.path ?? candidate?.file_path;
|
|
51
|
+
return typeof value === 'string' && value.length > 0 ? [value] : [];
|
|
52
|
+
}
|
|
@@ -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
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
name: cratis-engineering-docs-authoring
|
|
3
3
|
description: Draft accurate Cratis product or engineering documentation once the reader's goal, owning source, and product evidence are known. Use for tutorials, how-to guides, explanations, and references; use the owning repository's navigation and visual QA workflows for placement and rendering.
|
|
4
4
|
license: LICENSE
|
|
5
|
+
cratis-hint-paths:
|
|
6
|
+
- "**/Documentation/**/*.{md,mdx}"
|
|
5
7
|
---
|
|
6
8
|
|
|
7
9
|
# Cratis documentation authoring
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
name: cratis-specifications-csharp
|
|
3
3
|
description: Write C# specifications with Cratis.Specifications using the Establish/Because/should_ pattern and the for_/when_/and_ folder hierarchy. Use when adding or restructuring C# specs in any Cratis repository, choosing between an isolated unit spec and an in-process scenario spec, or building reusable given/ contexts. Do not use for TypeScript or React specs, and do not use it to decide what a command, projection, or reactor should do.
|
|
4
4
|
license: MIT
|
|
5
|
+
cratis-hint-paths:
|
|
6
|
+
- "**/for_*/**/*.cs"
|
|
5
7
|
---
|
|
6
8
|
|
|
7
9
|
# Cratis C# specifications
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
name: cratis-specifications-typescript
|
|
3
3
|
description: Write TypeScript specifications in the Cratis BDD style using the given() helper, reusable context classes, Sinon stubbing, and the Chai .should fluent interface. Use when adding or restructuring TypeScript specifications, building a given/ context class, or laying out a for_/when_ specification folder. Do not use for C# specifications, and do not use it to decide what the code under specification should do.
|
|
4
4
|
license: MIT
|
|
5
|
+
cratis-hint-paths:
|
|
6
|
+
- "**/for_*/**/*.ts"
|
|
7
|
+
- "**/for_*/**/*.tsx"
|
|
5
8
|
---
|
|
6
9
|
|
|
7
10
|
# Cratis TypeScript specifications
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
name: cratis-technical-examples
|
|
3
3
|
description: Design and verify developer-facing code samples, tutorial projects, and documentation snippets against real Cratis APIs. Use when adding or reviewing a runnable example, multi-client snippet, sample app, command/output pair, or migration before/after code. Do not invent API shapes or treat rendering as a compilation check.
|
|
4
4
|
license: MIT
|
|
5
|
+
cratis-hint-paths:
|
|
6
|
+
- "**/Samples/**/*.{cs,ts,tsx}"
|
|
5
7
|
---
|
|
6
8
|
|
|
7
9
|
# Technical examples readers can trust
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cratis/pi",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.2.0",
|
|
4
4
|
"description": "Configuration-aware Cratis AI integration for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"./src/index.ts",
|
|
42
42
|
"./package/corpus/harnesses/pi/extensions/cratis-hooks/index.ts",
|
|
43
43
|
"./package/corpus/harnesses/pi/extensions/cratis-mcp/index.ts",
|
|
44
|
+
"./package/corpus/harnesses/pi/extensions/cratis-path-guidance/index.ts",
|
|
44
45
|
"./package/corpus/harnesses/pi/extensions/subagent/index.ts"
|
|
45
46
|
]
|
|
46
47
|
}
|
package/src/index.ts
CHANGED
|
@@ -88,6 +88,16 @@ function ruleMatchesConfiguration(path: string, content: string, configuration:
|
|
|
88
88
|
(needsDocumentation && selectedProfiles.includes('cratis/documentation'));
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* A rule scoped by `applyTo` to particular files is delivered by the packaged `cratis-path-guidance`
|
|
93
|
+
* extension when such a file is touched, so it stays out of the system prompt. Only rules that apply to
|
|
94
|
+
* every file, or declare no scope, belong there.
|
|
95
|
+
*/
|
|
96
|
+
function isUniversalRule(content: string): boolean {
|
|
97
|
+
const applyTo = (frontmatterValue(content, 'applyTo') ?? '').split(',').map(glob => glob.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
98
|
+
return applyTo.length === 0 || applyTo.some(glob => ['**', '**/*', '*'].includes(glob));
|
|
99
|
+
}
|
|
100
|
+
|
|
91
101
|
function rules(cwd: string): string {
|
|
92
102
|
const root = join(corpusRoot, 'rules');
|
|
93
103
|
const configurationPath = join(cwd, '.cratis', 'ai.json');
|
|
@@ -99,6 +109,7 @@ function rules(cwd: string): string {
|
|
|
99
109
|
.sort()
|
|
100
110
|
.map(entry => ({ path: join(root, entry), content: readFileSync(join(root, entry), 'utf8') }))
|
|
101
111
|
.filter(rule => !configuration || ruleMatchesConfiguration(rule.path, rule.content, configuration))
|
|
112
|
+
.filter(rule => isUniversalRule(rule.content))
|
|
102
113
|
.map(rule => rule.content)
|
|
103
114
|
.join('\n\n');
|
|
104
115
|
}
|