@cratis/pi 2.23.0 → 2.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,27 +1,250 @@
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 } from 'node:fs';
5
- import { dirname, join, resolve } from 'node:path';
4
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
5
+ import { dirname, join, relative, resolve, sep } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
8
8
 
9
9
  const corpusRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
10
10
 
11
- /** Loads every rule from the managed corpus, including general guidance when AGENTS.md is project-owned. */
12
- export function managedRules(cwd: string): string {
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 {
13
94
  const managedRoot = join(cwd, '.cratis', 'ai', 'rules');
14
- const rulesRoot = existsSync(managedRoot) ? managedRoot : join(corpusRoot, 'rules');
15
- return readdirSync(rulesRoot, { recursive: true, encoding: 'utf8' })
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' })
16
125
  .filter((entry): entry is string => entry.endsWith('.md'))
17
126
  .sort()
18
- .map(entry => readFileSync(join(rulesRoot, entry), 'utf8'))
19
- .join('\n\n');
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)));
20
152
  }
21
153
 
22
- /** Adds every managed Cratis rule to Pi without requiring the @cratis/pi package. */
154
+ /** Upper bound on files considered from one bash command, so a wide command cannot deliver the corpus. */
155
+ const maxPathsPerCommand = 10;
156
+
157
+ /**
158
+ * Extracts file paths a bash command refers to. `rtk.md` tells agents to run `rtk read`, `rtk grep`
159
+ * and `rtk find` from the terminal for bulk reads, so file access frequently arrives as a bash
160
+ * command rather than the read tool; without this, a session that follows that rule would never
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.
200
+ *
201
+ * A delivered rule lives in the conversation rather than the system prompt, so anything that rewrites
202
+ * or replaces the conversation can remove it. The delivery record is therefore reset whenever that
203
+ * happens, and the rule is delivered again the next time one of its files is touched.
204
+ */
23
205
  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
+
24
218
  pi.on('before_agent_start', (event, context) => ({
25
- systemPrompt: `${event.systemPrompt}\n\n${managedRules(context.cwd)}`,
219
+ systemPrompt: `${event.systemPrompt}\n\n${universalRules(context.cwd).map(rule => rule.content).join('\n\n')}`,
26
220
  }));
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
+ });
27
250
  }
@@ -21,3 +21,7 @@ Invoking this prompt is direct authority for the standard branch, commit, push,
21
21
  requested-label, merge, and branch-cleanup effects. Do not pause to ask for separate approval at
22
22
  each step. Follow the repository's Git commit and pull-request rules, use a true merge commit,
23
23
  and verify required checks before merging.
24
+
25
+ This prompt is for an explicit request to ship or land. If the user asked only to commit, only to
26
+ push, or only to open a PR, stop after that step: the narrower request does not become authority
27
+ for the rest of the chain because this prompt happens to be loaded.
@@ -1,5 +1,7 @@
1
1
  ---
2
- applyTo: "**/*"
2
+ applyTo: "**/.cratis/**"
3
+ paths:
4
+ - "**/.cratis/**"
3
5
  ---
4
6
 
5
7
  # Shared AI Distribution
@@ -0,0 +1,73 @@
1
+ ---
2
+ applyTo: "**/*"
3
+ profile: application
4
+ ---
5
+
6
+ # Application profile
7
+
8
+ > The following — **Project Layout, Slice Types, Slice Naming, the Rules, and the Implementation Workflow** — applies when **building an application on Cratis**. If you are contributing to a Cratis framework repo, skip to **Framework profile** below and follow [framework.md](./framework.md).
9
+
10
+ ## Project Layout (Cratis Application convention)
11
+
12
+ The framework discovers commands and read models by attributes and static methods — **the folder shape is a convention, not a requirement.** The house default: the domain hierarchy lives directly under the app source root (**no top-level `Features/` wrapper**), cross-cutting concerns (`Common/` concepts, `Identity/`, shared components) at the top level, and one folder per behavior — `<Module>/<Feature>/<Slice>/` holding `<Slice>.cs`, the slice's `.tsx`, and its `when_*/` specs. **The slice is the invariant unit** — created, renamed, and deleted as one. Namespace mirrors the path under the source root, dropping any level that isn't present. The full tree and the split rules are in [vertical-slices.md](./vertical-slices.md).
13
+
14
+ ## Slice Types
15
+
16
+ Pick exactly one type per slice folder — determined by what the slice *does*.
17
+
18
+ | Type | What it does | Contents |
19
+ | --- | --- | --- |
20
+ | **State Change** | Accepts a command, appends events | Command + validator + event(s); optional `[Passive]` read model for command-side decisions |
21
+ | **State View** | Projects events into a queryable read model | `[ReadModel]` + model-bound projection + static query method(s) |
22
+ | **Automation** | Reacts to events, calls external systems / `ICommandPipeline` | Reactor only |
23
+ | **Translation** | Reacts to events and appends follow-up events to another stream | Reactor only |
24
+
25
+ ## Slice Naming (convention)
26
+
27
+ Commands are imperative intents (`Register`, `Create`); the slice folder is the action only, never repeating a noun the Feature already establishes. **`[EventType]` records are past-tense facts** and must be self-describing (`AuthorRegistered`, never `Created`) — this past-tense, one-purpose naming is a Chronicle framework recommendation. Static query methods are descriptive reads (`AllAuthors`, `AuthorById`, `AuthorsByName`).
28
+
29
+ ## Rules
30
+
31
+ Tagged **[contract]** (framework-enforced) or **[convention]** (house default). Mechanics and examples live in `vertical-slices.md`.
32
+
33
+ 1. **[contract] Model-bound — no controllers.** Commands are `[Command]` records with a public instance `Handle()` (Arc analyzers enforce this); queries are `static` methods on `[ReadModel]` records; projections/constraints/authorization use attributes. Arc generates the HTTP surface. Drop to fluent (`IProjectionFor<T>`, `IConstraint`) only when model-bound can't express the rule.
34
+ 2. **[contract] Command validation & data flow.** Put command rejection in `CommandValidator<T>`, global value invariants in `ConceptValidator<T>`, and fetched/computed handler data in **`Provide()`** (runs after validation/authorization; may short-circuit with `ValidationResult.Error` / `Result<TProvided, ValidationResult>`). Keep `Handle()` focused on event construction. For a state-dependent rule that must hold **under concurrency**, inject the read model into `Handle()` and return a typed error via `Result<TEvent, ValidationResult>`. **Throwing from `Provide()`/`Handle()` is an exception (HTTP 500), not a validation rejection** — throw only for genuinely exceptional conditions, never for normal business rejection.
35
+ 3. **[contract] Event-source id resolution:** `ICanProvideEventSourceId` first; else the first qualifying property in declaration order — an `EventSourceId`/`EventSourceId<T>`-derived value *or* a `[Key]` property (no priority between them; two candidates is `ARCCHR0002`); else Arc generates one. A value actually used as a Chronicle stream identity derives from `EventSourceId<T>` with the underlying `IComparable` primitive — never `ConceptAs<Guid>` for that stream identity. `NotSet`, `New()`, and primitive→derived-id operators are optional domain/API conveniences, not Chronicle requirements; typed empty/zero values are real specified stream IDs, not `EventSourceId.Unspecified`.
36
+ 4. **[contract] Events never carry the event-source id** — it is implicit in the event context.
37
+ 5. **[contract] `[Key]` / `[Subject]` are distinct.** `[Key]` is for event-source/read-model/projection key resolution; `[Subject]` is compliance identity only. Don't put either on an `EventSourceId<T>` value (it already is both); use them only for non-`EventSourceId<T>` values.
38
+ 6. **[contract] Avoid nullable event properties** — Chronicle's analyzer warns on them. Model optional facts as a separate event; resolve nullable command inputs to a non-null sentinel before constructing the event.
39
+ 7. **[contract] `[EventType]` takes no arguments for new events** — the type name is the identifier. Use `generation:`/id only when evolving an existing contract; schema changes get a new generation + an `EventTypeMigration<TUpgrade,TPrevious>` (never edit stored-event semantics silently). An enum that only gains a member, or has one renamed, is the exception — Chronicle accepts that in place; a *removed* or *renumbered* member still needs a generation, plus a value map saying what the old values became.
40
+ 8. **[convention] Every `[EventType]` has an XML `<summary>`** — a Cratis C# documentation convention (not a Chronicle rule); events live in the log forever, so record why they exist.
41
+ 9. **[convention] `[ReadModel]` properties carry no default values** except semantically meaningful enum initial states and `[SetValue<T>]`-driven `bool` flags. False defaults hide missing projection wiring.
42
+ 10. **[contract] AutoMap is on by default — never call `.AutoMap()`.** Match property names so AutoMap wires them; diverge with `[SetFrom<T>]` / fluent `.Set().To()` only for genuine name differences. Re-enable `.AutoMap()` only inside a scope where it was disabled with `.NoAutoMap()`.
43
+ 11. **[contract] Projections consume events and event context — never other read models.** Default to model-bound attributes; use fluent `IProjectionFor<T>` for joins/nested/context/transforms; use a reducer when the model is "current state + event → next state" (a valid style, not a failure mode).
44
+ 12. **[contract] Model-bound query custom paths use `[Path("...")]`** (`PathAttribute`), not ASP.NET `[Route]`. Reserve `[Route]` for controller-based endpoints (which this convention avoids).
45
+ 13. **[convention] Cross-slice access is read-only through Chronicle** — inject another slice's read model or reference its events; never instantiate or DI another slice's command/handler/service.
46
+ 14. **[contract] Never inject `IEventLog` into `Handle()`** — express appends through return types (`IEnumerable<object>` with `EventForEventSourceId` wrappers for cross-stream). In application reactors, return side-effect events or commands (or use `ICommandPipeline` and inspect the result); don't reach for `IEventLog` directly.
47
+ 15. **[contract] Never edit a generated file** — proxies carry a `// @generated by Cratis` header. Fix the C# source and rebuild.
48
+ 16. **[convention] Use the Cratis dialogs** — `CommandDialog` from `@cratis/components/CommandDialog`, `Dialog` from `@cratis/components/Dialogs`; never a vendor or hand-rolled modal. The default frontend stack is Cratis Components **4.x** (Components-owned markup, `--cratis-*` tokens, `pt`/`data-cratis-part` parts; no PrimeReact dependency) — **not** Tailwind (Tailwind is one supported way to write the token-mapping CSS, not the generic default).
49
+ 17. **[convention] One slice is one unit** — creating/renaming/moving/deleting a slice means doing the same to every artifact (the `.cs`, every `when_*/`, every `.tsx`, the composition import/JSX, the route).
50
+
51
+ ## Implementation Workflow
52
+
53
+ - **Phase 0 — Model.** Confirm Module/Feature, slice name, slice type, domain rules; for new behavior or unclear event vocabulary run the **event-modeling** skill first.
54
+ - **Phase 1 — Backend.** Implement a coherent slice change. **Gate:** incremental Debug build of the affected project (regenerates proxies, compiles `#if DEBUG` spec code).
55
+ - **Phase 2 — Specs.** Mandatory for every slice type, in-process scenario family first (`CommandScenario<T>`, `EventScenario`, `ReadModelScenario<T>`, `ReactorScenario<T>`). **Gate:** tests pass.
56
+ - **Phase 3 — Frontend.** Build from the generated proxies, register in the composition page, wire routing. **Gate:** lint, conditional test, build.
57
+
58
+ **Backend before frontend, always** — the frontend depends on proxies that exist only after a successful build. Generate proxies from the **Debug** build (house convention); verify Release without re-running the generator: `dotnet build -c Release -p:CratisProxiesOutputPath=`. Why that property, and what to do when nothing is generated: the **cratis-arc-command** skill's proxy-generation reference.
59
+
60
+ ## Quality Gates
61
+
62
+ | Phase | Command (app-pinned) | Pass criteria |
63
+ | --- | --- | --- |
64
+ | Backend | build (Debug) | zero errors, zero warnings — validates `#if DEBUG` spec code and regenerates proxies |
65
+ | Backend | build (Release) | zero errors, zero warnings — build-only check; pass `-p:CratisProxiesOutputPath=` to skip re-running proxy generation |
66
+ | Specs | test | zero failures |
67
+ | Frontend | lint | zero errors |
68
+ | Frontend | test | zero failures when frontend specs/behavior changed |
69
+ | Frontend | build | zero errors |
70
+
71
+ Run affected-project incremental checks after a coherent change, then targeted regression tests for the changed behavior. Re-run a failed gate after a relevant fix. Reserve wider matrices and clean/Release builds for cross-cutting changes, demonstrated stale outputs, or required merge/release gates. Documentation/rule-only edits need relevant Markdown, frontmatter, link, and corpus checks, not an application build. Diagnose unrelated or environmental failures within a bounded attempt; report the evidence and blocker instead of broadening scope or retrying indefinitely. Required gates remain blocking until satisfied; never silently waive red CI.
72
+
73
+ Documentation-only changes carry non-release intent and run the documentation checks, never a blanket exemption from red CI — see [pull-requests.md](./pull-requests.md).
@@ -95,78 +95,13 @@ A claim is only as good as the signal behind it — a build result, a test run,
95
95
  - **Confirm "done"/"fixed"/"correct" against a fresh signal — never self-assessment.** Run the relevant gate and observe it pass *this time*.
96
96
  - **After a fix, re-run the gate that failed.** Don't argue yourself to green.
97
97
  - **A green build is not behavioral correctness.** Compilation proves it builds, not that the slice does the right thing — that's what specs and exercising the UI are for.
98
- - **Report with inspectable evidence, and name what you didn't verify.**
98
+ - **Report the conclusion and what you didn't verify, in a line or two.** Show the output when asked, when a claim is contested, or when the check failed — see [verification-discipline.md](./verification-discipline.md).
99
99
 
100
100
  ---
101
101
 
102
102
  # Application profile
103
103
 
104
- > The following — **Project Layout, Slice Types, Slice Naming, the Rules, and the Implementation Workflow** — applies when **building an application on Cratis**. If you are contributing to a Cratis framework repo, skip to **Framework profile** below and follow [framework.md](./framework.md).
105
-
106
- ## Project Layout (Cratis Application convention)
107
-
108
- The framework discovers commands and read models by attributes and static methods — **the folder shape is a convention, not a requirement.** The house default: the domain hierarchy lives directly under the app source root (**no top-level `Features/` wrapper**), cross-cutting concerns (`Common/` concepts, `Identity/`, shared components) at the top level, and one folder per behavior — `<Module>/<Feature>/<Slice>/` holding `<Slice>.cs`, the slice's `.tsx`, and its `when_*/` specs. **The slice is the invariant unit** — created, renamed, and deleted as one. Namespace mirrors the path under the source root, dropping any level that isn't present. The full tree and the split rules are in [vertical-slices.md](./vertical-slices.md).
109
-
110
- ## Slice Types
111
-
112
- Pick exactly one type per slice folder — determined by what the slice *does*.
113
-
114
- | Type | What it does | Contents |
115
- | --- | --- | --- |
116
- | **State Change** | Accepts a command, appends events | Command + validator + event(s); optional `[Passive]` read model for command-side decisions |
117
- | **State View** | Projects events into a queryable read model | `[ReadModel]` + model-bound projection + static query method(s) |
118
- | **Automation** | Reacts to events, calls external systems / `ICommandPipeline` | Reactor only |
119
- | **Translation** | Reacts to events and appends follow-up events to another stream | Reactor only |
120
-
121
- ## Slice Naming (convention)
122
-
123
- Commands are imperative intents (`Register`, `Create`); the slice folder is the action only, never repeating a noun the Feature already establishes. **`[EventType]` records are past-tense facts** and must be self-describing (`AuthorRegistered`, never `Created`) — this past-tense, one-purpose naming is a Chronicle framework recommendation. Static query methods are descriptive reads (`AllAuthors`, `AuthorById`, `AuthorsByName`).
124
-
125
- ## Rules
126
-
127
- Tagged **[contract]** (framework-enforced) or **[convention]** (house default). Mechanics and examples live in `vertical-slices.md`.
128
-
129
- 1. **[contract] Model-bound — no controllers.** Commands are `[Command]` records with a public instance `Handle()` (Arc analyzers enforce this); queries are `static` methods on `[ReadModel]` records; projections/constraints/authorization use attributes. Arc generates the HTTP surface. Drop to fluent (`IProjectionFor<T>`, `IConstraint`) only when model-bound can't express the rule.
130
- 2. **[contract] Command validation & data flow.** Put command rejection in `CommandValidator<T>`, global value invariants in `ConceptValidator<T>`, and fetched/computed handler data in **`Provide()`** (runs after validation/authorization; may short-circuit with `ValidationResult.Error` / `Result<TProvided, ValidationResult>`). Keep `Handle()` focused on event construction. For a state-dependent rule that must hold **under concurrency**, inject the read model into `Handle()` and return a typed error via `Result<TEvent, ValidationResult>`. **Throwing from `Provide()`/`Handle()` is an exception (HTTP 500), not a validation rejection** — throw only for genuinely exceptional conditions, never for normal business rejection.
131
- 3. **[contract] Event-source id resolution:** `ICanProvideEventSourceId` first; else the first qualifying property in declaration order — an `EventSourceId`/`EventSourceId<T>`-derived value *or* a `[Key]` property (no priority between them; two candidates is `ARCCHR0002`); else Arc generates one. A value actually used as a Chronicle stream identity derives from `EventSourceId<T>` with the underlying `IComparable` primitive — never `ConceptAs<Guid>` for that stream identity. `NotSet`, `New()`, and primitive→derived-id operators are optional domain/API conveniences, not Chronicle requirements; typed empty/zero values are real specified stream IDs, not `EventSourceId.Unspecified`.
132
- 4. **[contract] Events never carry the event-source id** — it is implicit in the event context.
133
- 5. **[contract] `[Key]` / `[Subject]` are distinct.** `[Key]` is for event-source/read-model/projection key resolution; `[Subject]` is compliance identity only. Don't put either on an `EventSourceId<T>` value (it already is both); use them only for non-`EventSourceId<T>` values.
134
- 6. **[contract] Avoid nullable event properties** — Chronicle's analyzer warns on them. Model optional facts as a separate event; resolve nullable command inputs to a non-null sentinel before constructing the event.
135
- 7. **[contract] `[EventType]` takes no arguments for new events** — the type name is the identifier. Use `generation:`/id only when evolving an existing contract; schema changes get a new generation + an `EventTypeMigration<TUpgrade,TPrevious>` (never edit stored-event semantics silently). An enum that only gains a member, or has one renamed, is the exception — Chronicle accepts that in place; a *removed* or *renumbered* member still needs a generation, plus a value map saying what the old values became.
136
- 8. **[convention] Every `[EventType]` has an XML `<summary>`** — a Cratis C# documentation convention (not a Chronicle rule); events live in the log forever, so record why they exist.
137
- 9. **[convention] `[ReadModel]` properties carry no default values** except semantically meaningful enum initial states and `[SetValue<T>]`-driven `bool` flags. False defaults hide missing projection wiring.
138
- 10. **[contract] AutoMap is on by default — never call `.AutoMap()`.** Match property names so AutoMap wires them; diverge with `[SetFrom<T>]` / fluent `.Set().To()` only for genuine name differences. Re-enable `.AutoMap()` only inside a scope where it was disabled with `.NoAutoMap()`.
139
- 11. **[contract] Projections consume events and event context — never other read models.** Default to model-bound attributes; use fluent `IProjectionFor<T>` for joins/nested/context/transforms; use a reducer when the model is "current state + event → next state" (a valid style, not a failure mode).
140
- 12. **[contract] Model-bound query custom paths use `[Path("...")]`** (`PathAttribute`), not ASP.NET `[Route]`. Reserve `[Route]` for controller-based endpoints (which this convention avoids).
141
- 13. **[convention] Cross-slice access is read-only through Chronicle** — inject another slice's read model or reference its events; never instantiate or DI another slice's command/handler/service.
142
- 14. **[contract] Never inject `IEventLog` into `Handle()`** — express appends through return types (`IEnumerable<object>` with `EventForEventSourceId` wrappers for cross-stream). In application reactors, return side-effect events or commands (or use `ICommandPipeline` and inspect the result); don't reach for `IEventLog` directly.
143
- 15. **[contract] Never edit a generated file** — proxies carry a `// @generated by Cratis` header. Fix the C# source and rebuild.
144
- 16. **[convention] Use the Cratis dialogs** — `CommandDialog` from `@cratis/components/CommandDialog`, `Dialog` from `@cratis/components/Dialogs`; never a vendor or hand-rolled modal. The default frontend stack is Cratis Components **4.x** (Components-owned markup, `--cratis-*` tokens, `pt`/`data-cratis-part` parts; no PrimeReact dependency) — **not** Tailwind (Tailwind is one supported way to write the token-mapping CSS, not the generic default).
145
- 17. **[convention] One slice is one unit** — creating/renaming/moving/deleting a slice means doing the same to every artifact (the `.cs`, every `when_*/`, every `.tsx`, the composition import/JSX, the route).
146
-
147
- ## Implementation Workflow
148
-
149
- - **Phase 0 — Model.** Confirm Module/Feature, slice name, slice type, domain rules; for new behavior or unclear event vocabulary run the **event-modeling** skill first.
150
- - **Phase 1 — Backend.** Implement a coherent slice change. **Gate:** incremental Debug build of the affected project (regenerates proxies, compiles `#if DEBUG` spec code).
151
- - **Phase 2 — Specs.** Mandatory for every slice type, in-process scenario family first (`CommandScenario<T>`, `EventScenario`, `ReadModelScenario<T>`, `ReactorScenario<T>`). **Gate:** tests pass.
152
- - **Phase 3 — Frontend.** Build from the generated proxies, register in the composition page, wire routing. **Gate:** lint, conditional test, build.
153
-
154
- **Backend before frontend, always** — the frontend depends on proxies that exist only after a successful build. Generate proxies from the **Debug** build (house convention); verify Release without re-running the generator: `dotnet build -c Release -p:CratisProxiesOutputPath=`. Why that property, and what to do when nothing is generated: the **cratis-arc-command** skill's proxy-generation reference.
155
-
156
- ## Quality Gates
157
-
158
- | Phase | Command (app-pinned) | Pass criteria |
159
- | --- | --- | --- |
160
- | Backend | build (Debug) | zero errors, zero warnings — validates `#if DEBUG` spec code and regenerates proxies |
161
- | Backend | build (Release) | zero errors, zero warnings — build-only check; pass `-p:CratisProxiesOutputPath=` to skip re-running proxy generation |
162
- | Specs | test | zero failures |
163
- | Frontend | lint | zero errors |
164
- | Frontend | test | zero failures when frontend specs/behavior changed |
165
- | Frontend | build | zero errors |
166
-
167
- Run affected-project incremental checks after a coherent change, then targeted regression tests for the changed behavior. Re-run a failed gate after a relevant fix. Reserve wider matrices and clean/Release builds for cross-cutting changes, demonstrated stale outputs, or required merge/release gates. Documentation/rule-only edits need relevant Markdown, frontmatter, link, and corpus checks, not an application build. Diagnose unrelated or environmental failures within a bounded attempt; report the evidence and blocker instead of broadening scope or retrying indefinitely. Required gates remain blocking until satisfied; never silently waive red CI.
168
-
169
- Documentation-only changes carry non-release intent and run the documentation checks, never a blanket exemption from red CI — see [pull-requests.md](./pull-requests.md).
104
+ > **Building an application on Cratis?** Project layout, slice types, slice naming, the seventeen slice rules, the implementation workflow and the application quality gates are in [application-profile.md](./application-profile.md), which loads only for repositories that select an application profile. If you are contributing to a Cratis framework repo, see **Framework profile** below.
170
105
 
171
106
  ---
172
107
 
@@ -220,7 +155,7 @@ Documentation-only changes carry non-release intent and run the documentation ch
220
155
  - **Skills and rules are the authoritative answer.** If not answered there, ask. Don't infer Cratis behavior from package internals.
221
156
  - Only make high-confidence suggestions.
222
157
  - Don't change dependency manifests / lockfiles / `global.json` / NuGet config unless explicitly asked.
223
- - When asked to commit, push, create a PR, ship, or land changes, use the **ship-changes** prompt (`.cratis/ai/prompts/ship-changes.prompt.md`).
158
+ - When asked to **ship** or **land** changes, use the **ship-changes** prompt (`.cratis/ai/prompts/ship-changes.prompt.md`); invoking it is what authorizes the branch → commits → push → PR → merge → cleanup chain it describes. A request to only commit, only push, or only open a PR authorizes exactly that step, under [Git commits](./git-commits.md) and [Pull requests](./pull-requests.md) — do not route it through ship-changes and do not add the later steps.
224
159
 
225
160
  ## General
226
161
 
@@ -8,6 +8,11 @@ A guard that cannot fail is worse than no guard: it converts "nobody looked" int
8
8
  check. Every line is tagged **[contract]** (binding) or **[convention]** (the house
9
9
  default) per the Three Levels of Authority in [`general.md`](./general.md).
10
10
 
11
+ **Scope.** This rule governs two situations: *authoring* a scanner, guard, allowlist or
12
+ checker, and *running a pass* that mutates a computed set of subjects rather than the
13
+ targets the request named. An ordinary edit, review, fix, or a change to the one file the
14
+ user pointed at is outside it and carries none of the machinery below.
15
+
11
16
  ## Non-vacuity
12
17
 
13
18
  - **[contract] A scan over a possibly-empty population carries a non-vacuity check.**
@@ -28,19 +33,28 @@ default) per the Three Levels of Authority in [`general.md`](./general.md).
28
33
  - **[convention] Prefer an expiry to a permanent exemption.** An entry nobody revisits is
29
34
  a rule quietly deleted.
30
35
 
31
- ## Destructive passes
36
+ ## Bulk and irreversible passes
37
+
38
+ These contracts are keyed to the *risk class* of the effect — bulk deletion, history
39
+ rewriting, cross-repository migration, anything irreversible or high-fanout — not to
40
+ whether a person is watching. An autonomous session runs them exactly as an interactive
41
+ one does.
32
42
 
33
43
  - **[contract] Distinguish "subject set empty" from "qualifying set empty".** Finding no
34
44
  candidates at all is a different situation from finding candidates that none qualified;
35
- an unattended pass must refuse to proceed on the first.
36
- - **[contract] Every unattended destructive pass carries a per-pass fuse** — a maximum
37
- number of subjects it may act on in one run, which stops the run rather than trimming
38
- the work silently.
39
- - **[contract] Prepare the inverse before the forward action.** If an exact inverse or a
40
- safe compensation cannot be prepared, stop.
45
+ a pass must refuse to proceed on the first.
46
+ - **[contract] Every pass carries a per-pass fuse** — a maximum number of subjects it may
47
+ act on in one run, which stops the run rather than trimming the work silently.
48
+ - **[contract] Know the recovery before the forward action.** For a reversible effect,
49
+ know how it is undone. For an irreversible one, preserve what the recovery needs first —
50
+ a backup ref, a copy under `.ai-work/keep/`, the list of targets — and stop if nothing
51
+ can be preserved and the request did not name the targets. Do not demand an exact
52
+ inverse where none can exist.
41
53
  - **[contract] Re-read preconditions immediately before each mutation and stop when drift
42
54
  invalidates the authorized scope, safety assumptions, or recovery plan.** Benign drift
43
55
  within an already authorized bounded pass does not require another confirmation.
44
- - **[convention] Dry-run output is the review artifact for a destructive pass whose exact
45
- targets were not already established in the conversation.** A user who has reviewed
46
- and authorized those targets is not asked to approve the same pass again.
56
+ - **[convention] When the targets were not already established in the conversation, show
57
+ the dry-run list and act on it once the user has answered.** A user who has reviewed
58
+ and authorized those targets is not asked to approve the same pass again. The list is a
59
+ message in the conversation, not a retained artifact: do not write receipts, ledgers,
60
+ snapshots or escrow copies of it.
@@ -21,6 +21,12 @@ dumps, and similar coordination files. These are **work records, not documentati
21
21
  - Knowledge that must outlive the session (real documentation, ADRs, operator
22
22
  guides) is written deliberately into the repository's documentation structure
23
23
  through normal review — not left behind as a work record.
24
+ - **A worktree is a work record too.** Remove the worktree you created for a task
25
+ once its branch is merged to the default branch — pushed is not merged. Do it from
26
+ the parent checkout, after `git status --ignored` in the worktree, because a nested
27
+ `.ai-work/`, `.env` files and local databases are ignored and are deleted with it.
28
+ If the branch must outlive the task unmerged, leave the worktree and say so in the
29
+ handoff. Never `--force` a removal; a refusal means look, not push harder.
24
30
  - **A decision log is not a work record.** A decision — a durable choice with a
25
31
  decider and a date — is documentation: it lives in **`decisions/`** (or the
26
32
  repository's documented decisions folder) and is reviewed like any other
@@ -1,3 +1,9 @@
1
+ ---
2
+ applyTo: "**/.cratis/**"
3
+ paths:
4
+ - "**/.cratis/**"
5
+ ---
6
+
1
7
  # AI Corpus Profiles
2
8
 
3
9
  Profiles determine which rules and skills apply to your work. They define the scope of the AI corpus and which documentation, conventions, and skills are available.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  applyTo: "**/*"
3
- description: "Use when running any shell command, reading files, or searching code. Route every command rtk supports through rtk (a hook auto-rewrites Bash commands); call rtk read/grep/find directly because the built-in Read/Grep/Glob tools bypass that hook."
3
+ description: "Use when running any shell command, reading files, or searching code. Route commands through rtk for human-readable output (a hook auto-rewrites Bash commands; prefix rtk yourself where it does not); use raw output when the result is parsed, compared exactly, or drives another operation."
4
4
  ---
5
5
 
6
6
  # Using rtk (Token-Optimized Commands)
@@ -9,11 +9,21 @@ description: "Use when running any shell command, reading files, or searching co
9
9
 
10
10
  ## How it works — let the hook do its job
11
11
 
12
- A `PreToolUse` hook **auto-rewrites Bash commands** to their `rtk` equivalent transparently and at zero token overhead (`git status` → `rtk git status`). For any supported command you do **nothing special** — run it normally and the hook wraps it.
12
+ A `PreToolUse` hook **auto-rewrites Bash commands** to their `rtk` equivalent transparently and at zero token overhead (`git status` → `rtk git status`). For any supported command you do **nothing special** — run it normally and the hook wraps it. Where no hook is configured, prefix the command yourself — including each command in an `&&` chain: `rtk git add . && rtk git commit -m "msg"`.
13
13
 
14
- - **Never bypass it.** Don't disable the hook, and reserve `rtk proxy <cmd>` for the rare case where you genuinely need the raw, unfiltered output (e.g. debugging what a filter dropped).
15
14
  - **Audit coverage** with `rtk gain` (savings so far) and `rtk discover` (commands that slipped past rtk — missed opportunities to close).
16
15
 
16
+ ## When to use raw output instead
17
+
18
+ rtk output is a summary for a reader. It is never evidence of exact file contents, paths, Git state, or command semantics, and a filter can drop the line you needed. Use the raw command — `rtk proxy <cmd>`, or the plain command where nothing rewrites it — whenever:
19
+
20
+ - the output is parsed by a script, compared exactly, or fed to another command;
21
+ - the output is the evidence for a claim about content, paths, or Git state;
22
+ - the command's exit code or exact diagnostics decide what happens next (security checks, structured JSON, machine-readable diagnostics);
23
+ - a wrapped command failed. Read the actual error and retry through `rtk proxy` before concluding the tool, path, or repository is missing.
24
+
25
+ Do not add `rtk` to scripts, hooks, or CI steps that deliberately use native commands; their raw output is the point. Exit codes are never traded for token savings — see [`exit-codes-and-wrappers.md`](./exit-codes-and-wrappers.md).
26
+
17
27
  ## What rtk supports (route these through rtk)
18
28
 
19
29
  - **Files** — `ls`, `tree`, `read`, `find`, `grep`, `diff`
@@ -14,4 +14,10 @@ authored a file or preserve a chain of evidence about how it arrived.
14
14
  - Keep each check deterministic and runnable locally and in CI.
15
15
  - Never replace a real behavior check with a checksum, inventory, generated
16
16
  receipt, or provenance record.
17
- - Report failures and skipped checks honestly.
17
+ - Documentary evidence — a receipt, ledger, attestation, hash inventory,
18
+ snapshot, escrow copy, or ad-hoc `verify-*` script — is not verification and
19
+ is not produced unless a repository workflow (such as a governed release) or
20
+ the user asks for it. The check's own output is the evidence.
21
+ - Report the conclusion and any real uncertainty in a line or two, not the
22
+ audit trail. Show the output when asked, when a claim is contested, or when
23
+ the check failed. Report failures and skipped checks honestly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/pi",
3
- "version": "2.23.0",
3
+ "version": "2.23.1",
4
4
  "description": "Configuration-aware Cratis AI integration for Pi",
5
5
  "type": "module",
6
6
  "files": [
@@ -1,19 +0,0 @@
1
- ---
2
- applyTo: "**/*"
3
- description: "Use when running any terminal command. Prefix every command with rtk -- including each command in an && chain -- so token-heavy output is filtered."
4
- ---
5
-
6
- # RTK (Rust Token Killer) - Token-Optimized Commands
7
-
8
- ## Golden Rule
9
-
10
- **Always prefix commands with `rtk`**. If RTK has a dedicated filter, it uses it. If not, it passes through unchanged. This means RTK is always safe to use.
11
-
12
- **Important**: Even in command chains with `&&`, use `rtk`:
13
- ```bash
14
- # ❌ Wrong
15
- git add . && git commit -m "msg" && git push
16
-
17
- # ✅ Correct
18
- rtk git add . && rtk git commit -m "msg" && rtk git push
19
- ```