diff --git a/.cratis/ai.manifest.json b/.cratis/ai.manifest.json index f3c65a1e..522c946b 100644 --- a/.cratis/ai.manifest.json +++ b/.cratis/ai.manifest.json @@ -1,5 +1,5 @@ { - "SourceRevision": "2038ba23f4006767101a0940dffd6022e9a3a28c", + "SourceRevision": "e7dc6fea47fe743e620d015076855ea2087b0a4e", "Files": [ { "Source": "agents/backend-developer.md", @@ -14,7 +14,7 @@ { "Source": "agents/coordinator.md", "Destination": "agents/coordinator.md", - "Hash": "BE22679F79F9D1115AF0ED6B0215697EB26C201C23C9A1D9E8DADD91F9BE1B9C" + "Hash": "D12122467A92188735349926FCE6DFDE753AA4BC60EFE8C53E3CD441EEBD1462" }, { "Source": "agents/frontend-developer.md", @@ -24,7 +24,7 @@ { "Source": "agents/orchestrator.md", "Destination": "agents/orchestrator.md", - "Hash": "A30F75D08B0189C995FB9FD764C0658DB6828C21C5B95DC46378E46EFCD0EC41" + "Hash": "6752A0302CF00089962899B39EB33F724D611C7B253234114A52ACA01BB9F48D" }, { "Source": "agents/performance-reviewer.md", @@ -34,7 +34,7 @@ { "Source": "agents/planner.md", "Destination": "agents/planner.md", - "Hash": "5D2BF7E2656B9EC814AB82ED0FB094221C2BFD3A291CC1D9E310CECA907888B6" + "Hash": "EAAD6F8A19663DCEAFD73B7D606FF1EFCC1B2DEFED01D2684A63E38FE04F062E" }, { "Source": "agents/repository-investigation-reviewer.md", @@ -79,7 +79,7 @@ { "Source": "harnesses/opencode/agents/coordinator.md", "Destination": "harnesses/opencode/agents/coordinator.md", - "Hash": "F87FF9A4851FD72D63D123D7CC08A93C0D6DED2B6F86B21C3D23331638CC21FB" + "Hash": "1C2552AA0DF0FE59603FEFC710771AA41340A84378B853EA7105A29C2E01B917" }, { "Source": "harnesses/opencode/agents/frontend-developer.md", @@ -89,7 +89,7 @@ { "Source": "harnesses/opencode/agents/orchestrator.md", "Destination": "harnesses/opencode/agents/orchestrator.md", - "Hash": "496DF848E5199A6A75992A789F73DFA0647919E7A977ECF65A3A145A85C66E9E" + "Hash": "BE0DDABF827D920324D5A6290FAE60B00E940DE6CFE6822A78DAD36A5EC2E90E" }, { "Source": "harnesses/opencode/agents/performance-reviewer.md", @@ -99,7 +99,7 @@ { "Source": "harnesses/opencode/agents/planner.md", "Destination": "harnesses/opencode/agents/planner.md", - "Hash": "ED41FB7E6DB14F04490488759C9445F00C154E4F6BB0AD0EE3DBA1CBD6953AB0" + "Hash": "81DA2FE9E632DE33C2D915276FC02C3F1B270DC7D298D5FCE948789491F0B73F" }, { "Source": "harnesses/opencode/agents/repository-investigation-reviewer.md", @@ -176,16 +176,101 @@ "Destination": "harnesses/pi/extensions/cratis-mcp/protocol.ts", "Hash": "00581BF63FF7FE7B887354A6F30BFFAC4DA879C8D1AD07E116C6217A5A894AE0" }, + { + "Source": "harnesses/pi/extensions/cratis-path-guidance/LoadedSkill.ts", + "Destination": "harnesses/pi/extensions/cratis-path-guidance/LoadedSkill.ts", + "Hash": "D4803B90FC81B10D3C9E6452703169E33F2F4BFF57AAAA8E1E31D0872F79AFC6" + }, + { + "Source": "harnesses/pi/extensions/cratis-path-guidance/SkillMatch.ts", + "Destination": "harnesses/pi/extensions/cratis-path-guidance/SkillMatch.ts", + "Hash": "F4643AD14587192EF64FEEF9FF5455668951A681F8B841C97D76D9DD604035F1" + }, + { + "Source": "harnesses/pi/extensions/cratis-path-guidance/SkillTrigger.ts", + "Destination": "harnesses/pi/extensions/cratis-path-guidance/SkillTrigger.ts", + "Hash": "F5F1D27B86BBEA315E107633B64C217E070C6448671C40B9450C4FC72E2D13C1" + }, + { + "Source": "harnesses/pi/extensions/cratis-path-guidance/ToolName.ts", + "Destination": "harnesses/pi/extensions/cratis-path-guidance/ToolName.ts", + "Hash": "F1FB1EC64454464702FF94320E88FD3B57E7573E4806F5639DA0A71F75E74A50" + }, + { + "Source": "harnesses/pi/extensions/cratis-path-guidance/index.ts", + "Destination": "harnesses/pi/extensions/cratis-path-guidance/index.ts", + "Hash": "0465709FABDC841B4C0B01ABEB652F29DD3ECDA3F9C6266A324B7148321ACFBB" + }, + { + "Source": "harnesses/pi/extensions/cratis-path-guidance/skillReads.ts", + "Destination": "harnesses/pi/extensions/cratis-path-guidance/skillReads.ts", + "Hash": "955AF74A30715B49A960216B2B8A5C03331862D5783B3871CFA8C745A08C5B12" + }, + { + "Source": "harnesses/pi/extensions/cratis-path-guidance/skills.ts", + "Destination": "harnesses/pi/extensions/cratis-path-guidance/skills.ts", + "Hash": "3B436920AEA9BFE57ACF9B17361ED45F61D75447367B7BF5B1F561E0A74D7A67" + }, + { + "Source": "harnesses/pi/extensions/cratis-path-guidance/standDown.ts", + "Destination": "harnesses/pi/extensions/cratis-path-guidance/standDown.ts", + "Hash": "FE5A1C69966D65C4D8E1CDBEE89884302A1468F4FF0DFDEE3907D81D66AFCAAB" + }, + { + "Source": "harnesses/pi/extensions/cratis-path-guidance/touchedPaths.ts", + "Destination": "harnesses/pi/extensions/cratis-path-guidance/touchedPaths.ts", + "Hash": "0C7538BB403B9D7C659D26E5E3565BD6BD4241FAF30413F2CF3F42D659A2F53C" + }, { "Source": "harnesses/pi/extensions/cratis-rules/index.ts", "Destination": "harnesses/pi/extensions/cratis-rules/index.ts", - "Hash": "355706DD005B8B828B50205C0763C9EDA99431DF47B2EC655376C382C99F4B52" + "Hash": "44B62C8A2918C9D7FFD2D039C73851F2F0BE92945FFFB9B86C02ADB01549EA2B" }, { "Source": "harnesses/pi/extensions/package.json", "Destination": "harnesses/pi/extensions/package.json", "Hash": "0D6D88F0FFDB79E4ED4D47D6EF5280B033B69787DAC7A8EF2D015137CAFA8ADC" }, + { + "Source": "harnesses/pi/extensions/shared/AiConfiguration.ts", + "Destination": "harnesses/pi/extensions/shared/AiConfiguration.ts", + "Hash": "126EDDE395776039D2EDB233916D22B2067AF8A6E8373192114E104E6601150C" + }, + { + "Source": "harnesses/pi/extensions/shared/ManagedRule.ts", + "Destination": "harnesses/pi/extensions/shared/ManagedRule.ts", + "Hash": "9C0A06F6DE6A4BB90D30556EE4901832646332E6B2A269CD78C8BA86459EE8A2" + }, + { + "Source": "harnesses/pi/extensions/shared/corpusRoot.ts", + "Destination": "harnesses/pi/extensions/shared/corpusRoot.ts", + "Hash": "FF2613445459E583FFB9EE544348CC65AB8A44AF54722FFCC166FA56E7CD2D3F" + }, + { + "Source": "harnesses/pi/extensions/shared/frontmatter.ts", + "Destination": "harnesses/pi/extensions/shared/frontmatter.ts", + "Hash": "D1B96A98A8100A6CF8690F0140F5838C89D9EF990F1DD110CC05389EF203EBE8" + }, + { + "Source": "harnesses/pi/extensions/shared/globs.ts", + "Destination": "harnesses/pi/extensions/shared/globs.ts", + "Hash": "2ECE0BDF06105B7669905EE882DEAF0CDEB45921861CBF82DE179AF412606E2D" + }, + { + "Source": "harnesses/pi/extensions/shared/rules.ts", + "Destination": "harnesses/pi/extensions/shared/rules.ts", + "Hash": "8DF8A0183AC30B6EF2B6A3D6481FD7A4CA971DEA19C384C8E6C25A66FA683D0B" + }, + { + "Source": "harnesses/pi/extensions/shared/skillFrontmatter.ts", + "Destination": "harnesses/pi/extensions/shared/skillFrontmatter.ts", + "Hash": "31913D4368A0DA79E212CD896E12D712662D8F733141F155F88A69B04A5F90E8" + }, + { + "Source": "harnesses/pi/extensions/shared/skillSelection.ts", + "Destination": "harnesses/pi/extensions/shared/skillSelection.ts", + "Hash": "0552117254EE4807DBBAA3474C631C221995F8F5533D91475850BC1D7ABE27E9" + }, { "Source": "harnesses/pi/extensions/subagent/agents.ts", "Destination": "harnesses/pi/extensions/subagent/agents.ts", @@ -369,7 +454,7 @@ { "Source": "prompts/ship-changes.prompt.md", "Destination": "prompts/ship-changes.prompt.md", - "Hash": "3C4C8959A7A493910CC9FBED706BC6E86EBCE3E827F60273DAC1B2E1CAB2B66F" + "Hash": "03C1F84B54B97A81EE3B0E6EF906DB3B2C01F70031E1A49C6A3A21D9D551072B" }, { "Source": "prompts/verify-ai-setup.prompt.md", @@ -504,7 +589,7 @@ { "Source": "rules/pull-requests.md", "Destination": "rules/pull-requests.md", - "Hash": "5931286AA9BB4D5B3BE5085029AF70CC4B87D1E7B40CF791A7667A49019DBBE9" + "Hash": "84EC62EC4B35B34646D204EF803D96E9483EC8D3B9AD2228583553D008B2E114" }, { "Source": "rules/rtk.md", @@ -559,7 +644,7 @@ { "Source": "skills/cratis-application-react-specifications/SKILL.md", "Destination": "skills/cratis-application-react-specifications/SKILL.md", - "Hash": "69F13AEA9B1135C2A64C1B8D48E822290A512442533BDEF13F07364AE21FEA83" + "Hash": "98601657B9BF860ECA85896DD373824832B86E268C8553F3466DCDDED2B564C5" }, { "Source": "skills/cratis-arc-authentication-authorization-and-identity/LICENSE", @@ -829,12 +914,12 @@ { "Source": "skills/cratis-documentation-writing/SKILL.md", "Destination": "skills/cratis-documentation-writing/SKILL.md", - "Hash": "E6F18045341A9109036355E640D8E3873BCBE45EA7BAE559D400A2A54C19610C" + "Hash": "665A62930F7251C724EC71788AB48091478FA9C0AC2F15CF5EC7E398D0CEB8B1" }, { "Source": "skills/cratis-documentation-writing/references/cratis-site.md", "Destination": "skills/cratis-documentation-writing/references/cratis-site.md", - "Hash": "C69A51E7F93EEF53B02156267B84C2237CBBB87CEDBA74C5F0BCACDC40181D9A" + "Hash": "C31B21A36049266FF7D746FD1847CB47F59F2C59678A3EC2FE0264A1A0E1245F" }, { "Source": "skills/cratis-engineering-decision-record/LICENSE", @@ -859,7 +944,7 @@ { "Source": "skills/cratis-engineering-docs-authoring/SKILL.md", "Destination": "skills/cratis-engineering-docs-authoring/SKILL.md", - "Hash": "FCA63220C495BC5429CA3A694EE72C4778B15DC195DB780BEFB5D2D74A9F3D58" + "Hash": "4E49F04C9695BACC082E9D3B2C1CB81826D80A0FD0CB2C5DD02B071C36E7EFE2" }, { "Source": "skills/cratis-engineering-docs-authoring/references/site-format.md", @@ -939,7 +1024,7 @@ { "Source": "skills/cratis-release-notes/SKILL.md", "Destination": "skills/cratis-release-notes/SKILL.md", - "Hash": "37F86873C0020D1D37C14E145C5A50427D3CE8665B2D18F039728DD180307622" + "Hash": "E8BC64393DB799401D3910DAEF30C8D9FF802A24F06D1373A5FAA58691D50E13" }, { "Source": "skills/cratis-security-review/LICENSE", @@ -969,7 +1054,7 @@ { "Source": "skills/cratis-specifications-typescript/SKILL.md", "Destination": "skills/cratis-specifications-typescript/SKILL.md", - "Hash": "2F24C9C12C09EB81C85B79346E4C18296ADB7F44F71D19D15BEF93B0A22D6541" + "Hash": "A8D99CDE8171BE5D5B4FB319189637F127E3F75CFE7FBA1B55825C6E7B97688A" }, { "Source": "skills/cratis-specifications-typescript/references/typescript-patterns.md", @@ -984,7 +1069,7 @@ { "Source": "skills/cratis-technical-examples/SKILL.md", "Destination": "skills/cratis-technical-examples/SKILL.md", - "Hash": "DD4A9F573527661BE93C21B90CDD60D34A8F2A2B7983EEAAABAE2880EB3C070B" + "Hash": "D8E9720F9D9E867A9341B6A04D35AF6C90D587BBD14C971F674FEC1BAD392732" }, { "Source": "skills/cratis-writing-voice-and-cadence/LICENSE", diff --git a/.cratis/ai/agents/coordinator.md b/.cratis/ai/agents/coordinator.md index 66bfc717..4d7a8b38 100644 --- a/.cratis/ai/agents/coordinator.md +++ b/.cratis/ai/agents/coordinator.md @@ -129,7 +129,7 @@ For implementation, the applicable changed-lane gates must pass. Mark unrelated - [ ] `Documentation/verify-markdown.sh` passes when documentation is added or changed - [ ] `code-reviewer` finds no blocking issues - [ ] `security-reviewer` finds no vulnerabilities -- [ ] PR description follows the pull request template +- [ ] PR description follows the pull request template and the release-note contract in `pull-requests.md`; test and review notes are in a PR comment --- diff --git a/.cratis/ai/agents/orchestrator.md b/.cratis/ai/agents/orchestrator.md index ed43fa6f..38af0835 100644 --- a/.cratis/ai/agents/orchestrator.md +++ b/.cratis/ai/agents/orchestrator.md @@ -153,7 +153,7 @@ For implementation, the applicable changed-lane gates must pass. Mark unrelated - [ ] `code-reviewer` finds no blocking issues - [ ] `security-reviewer` finds no vulnerabilities - [ ] All documentation is complete and accurate (if required) -- [ ] PR description follows the pull request template +- [ ] PR description follows the pull request template and the release-note contract in `pull-requests.md`; test and review notes are in a PR comment --- diff --git a/.cratis/ai/agents/planner.md b/.cratis/ai/agents/planner.md index 990b2d6f..9c6cd202 100644 --- a/.cratis/ai/agents/planner.md +++ b/.cratis/ai/agents/planner.md @@ -116,7 +116,7 @@ For an implemented application slice, require the applicable changed-lane gates - [ ] `Documentation/verify-markdown.sh` passes when documentation is added or changed - [ ] Code review by `code-reviewer` finds no blocking issues - [ ] Security review by `security-reviewer` finds no vulnerabilities -- [ ] PR description follows the pull request template +- [ ] PR description follows the pull request template and the release-note contract in `pull-requests.md`; test and review notes are in a PR comment --- diff --git a/.cratis/ai/harnesses/opencode/agents/coordinator.md b/.cratis/ai/harnesses/opencode/agents/coordinator.md index ff759c4c..63d8a5c2 100644 --- a/.cratis/ai/harnesses/opencode/agents/coordinator.md +++ b/.cratis/ai/harnesses/opencode/agents/coordinator.md @@ -127,7 +127,7 @@ For implementation, the applicable changed-lane gates must pass. Mark unrelated - [ ] `Documentation/verify-markdown.sh` passes when documentation is added or changed - [ ] `code-reviewer` finds no blocking issues - [ ] `security-reviewer` finds no vulnerabilities -- [ ] PR description follows the pull request template +- [ ] PR description follows the pull request template and the release-note contract in `pull-requests.md`; test and review notes are in a PR comment --- diff --git a/.cratis/ai/harnesses/opencode/agents/orchestrator.md b/.cratis/ai/harnesses/opencode/agents/orchestrator.md index ee7eebd7..c0e77a63 100644 --- a/.cratis/ai/harnesses/opencode/agents/orchestrator.md +++ b/.cratis/ai/harnesses/opencode/agents/orchestrator.md @@ -151,7 +151,7 @@ For implementation, the applicable changed-lane gates must pass. Mark unrelated - [ ] `code-reviewer` finds no blocking issues - [ ] `security-reviewer` finds no vulnerabilities - [ ] All documentation is complete and accurate (if required) -- [ ] PR description follows the pull request template +- [ ] PR description follows the pull request template and the release-note contract in `pull-requests.md`; test and review notes are in a PR comment --- diff --git a/.cratis/ai/harnesses/opencode/agents/planner.md b/.cratis/ai/harnesses/opencode/agents/planner.md index e06ab57e..8dd337f1 100644 --- a/.cratis/ai/harnesses/opencode/agents/planner.md +++ b/.cratis/ai/harnesses/opencode/agents/planner.md @@ -114,7 +114,7 @@ For an implemented application slice, require the applicable changed-lane gates - [ ] `Documentation/verify-markdown.sh` passes when documentation is added or changed - [ ] Code review by `code-reviewer` finds no blocking issues - [ ] Security review by `security-reviewer` finds no vulnerabilities -- [ ] PR description follows the pull request template +- [ ] PR description follows the pull request template and the release-note contract in `pull-requests.md`; test and review notes are in a PR comment --- diff --git a/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/LoadedSkill.ts b/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/LoadedSkill.ts new file mode 100644 index 00000000..c532b2a6 --- /dev/null +++ b/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/LoadedSkill.ts @@ -0,0 +1,10 @@ +// cratis-ai-managed: harnesses/pi/extensions/cratis-path-guidance/LoadedSkill.ts +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** The part of a Pi skill (`event.systemPromptOptions.skills`) this extension reads. */ +export interface LoadedSkill { + name: string; + filePath: string; + baseDir?: string; +} diff --git a/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/SkillMatch.ts b/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/SkillMatch.ts new file mode 100644 index 00000000..1f4b59ec --- /dev/null +++ b/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/SkillMatch.ts @@ -0,0 +1,11 @@ +// cratis-ai-managed: harnesses/pi/extensions/cratis-path-guidance/SkillMatch.ts +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { SkillTrigger } from './SkillTrigger.ts'; + +/** A skill whose trigger matched a path, with the glob that matched. */ +export interface SkillMatch { + skill: SkillTrigger; + glob: string; +} diff --git a/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/SkillTrigger.ts b/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/SkillTrigger.ts new file mode 100644 index 00000000..a4e70e37 --- /dev/null +++ b/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/SkillTrigger.ts @@ -0,0 +1,14 @@ +// cratis-ai-managed: harnesses/pi/extensions/cratis-path-guidance/SkillTrigger.ts +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** A skill available to the session together with the path globs its `SKILL.md` declares as triggers. */ +export interface SkillTrigger { + name: string; + /** Absolute path of the skill's `SKILL.md`. */ + filePath: string; + /** Directory holding `SKILL.md` and the skill's references. */ + baseDir: string; + /** The `cratis-hint-paths` frontmatter of the skill. Empty when the skill declares no trigger. */ + globs: string[]; +} diff --git a/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/ToolName.ts b/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/ToolName.ts new file mode 100644 index 00000000..ba31668b --- /dev/null +++ b/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/ToolName.ts @@ -0,0 +1,12 @@ +// cratis-ai-managed: harnesses/pi/extensions/cratis-path-guidance/ToolName.ts +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** The Pi tools whose results carry a file path this extension reacts to. */ +export enum ToolName { + Read = 'read', + Write = 'write', + Edit = 'edit', + Bash = 'bash', + PowerShell = 'powershell', +} diff --git a/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/index.ts b/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/index.ts new file mode 100644 index 00000000..17ad45f8 --- /dev/null +++ b/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/index.ts @@ -0,0 +1,167 @@ +// cratis-ai-managed: harnesses/pi/extensions/cratis-path-guidance/index.ts +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; +import type { ManagedRule } from '../shared/ManagedRule.ts'; +import { rulesForPath } from '../shared/rules.ts'; +import type { LoadedSkill } from './LoadedSkill.ts'; +import type { SkillMatch } from './SkillMatch.ts'; +import type { SkillTrigger } from './SkillTrigger.ts'; +import { skillsRead } from './skillReads.ts'; +import { skillsForPath, skillTriggers } from './skills.ts'; +import { standsDown } from './standDown.ts'; +import { ToolName } from './ToolName.ts'; +import { touchedPaths } from './touchedPaths.ts'; + +const extensionDirectory = dirname(fileURLToPath(import.meta.url)); +const isPackagedExtension = extensionDirectory.includes(`${sep}package${sep}corpus${sep}`); + +function repositoryRelative(cwd: string, path: string): string | undefined { + const relativePath = relative(cwd, resolve(cwd, path)); + return !relativePath || relativePath.startsWith('..') || isAbsolute(relativePath) ? undefined : relativePath.split(sep).join('/'); +} + +function displayPath(cwd: string, path: string): string { + return repositoryRelative(cwd, path) ?? path; +} + +/** One line for everything a write matched, however many skills, so overlapping triggers cost one line and not several. */ +function hintLine(cwd: string, files: string[], matches: SkillMatch[]): string { + const skills = matches.map(match => `\`${match.skill.name}\` (matched \`${match.glob}\`)`); + const named = skills.length === 1 ? `Skill ${skills[0]} covers` : `Skills ${skills.slice(0, -1).join(', ')} and ${skills[skills.length - 1]} cover`; + const documents = matches.map(match => displayPath(cwd, match.skill.filePath)); + const read = documents.length === 1 ? documents[0] : `${documents.slice(0, -1).join(', ')} and ${documents[documents.length - 1]}`; + return `[cratis-path-guidance] ${named} ${files.join(', ')}; read ${read} before continuing.`; +} + +/** + * pi-subagents writes `# Preloaded Skill: ` for every name in `skills:` and then the skill's text, or a + * placeholder when it could not load it: `(Skill "" not found in ...)` or `(Skill "" skipped: ...)`. + * It cannot load a skill from a symlinked skills root, which is how a managed installation exposes + * `.pi/skills` and `.agents/skills`. Only a header followed by real content puts the skill in context. + */ +const placeholderBody = /^\(Skill "[^"]*" (?:not found|skipped)\b/; + +/** The skills pi-subagents preloaded into the system prompt (`skills: a, b`) whose full text is already in context. */ +function preloadedSkillNames(systemPrompt: unknown): Set { + const names = new Set(); + if (typeof systemPrompt !== 'string') return names; + for (const match of systemPrompt.matchAll(/^# Preloaded Skill: (\S+)[ \t]*\r?\n/gm)) { + const body = systemPrompt.slice(match.index + match[0].length).split(/^# Preloaded Skill: /m)[0].trim(); + if (body.length > 0 && !placeholderBody.test(body)) names.add(match[1]); + } + return names; +} + +/** The skills a `/skill:name` command expanded into the user message, which arrive without a `read` call. */ +function expandedSkillNames(prompt: unknown): string[] { + return typeof prompt === 'string' ? [...prompt.matchAll(/ match[1]) : []; +} + +/** + * Delivers the corpus guidance that belongs to a file at the moment the file is touched, so it works in every + * Pi process, including a subagent, without the universal rules `cratis-rules` puts in every system prompt. + * + * Path-scoped rules are attached to the tool result the first time a matching file is touched in the session, + * exactly as they were when `cratis-rules` delivered them. On a successful `write` or `edit`, one advisory line + * names every skill whose `cratis-hint-paths` frontmatter matches the file and where its `SKILL.md` is. A skill + * is not hinted when it is already in context: read in the session, preloaded by pi-subagents + * (`# Preloaded Skill: ` followed by its text in the system prompt), or expanded by `/skill:`. Hints are advisory only: + * nothing is blocked and the system prompt is never touched. + * + * Delivery happens on `tool_result`, so guidance arrives after the call that first touched the file. + * `ToolCallEventResult` carries only `block`/`reason`/`terminate`, so there is no supported way to add + * context before a tool runs. In practice a file is read before it is edited, and reads through both the read + * tool and bash are covered; a file created blind by `write` is the residual case, and it receives the + * guidance with that result. + * + * Delivered guidance lives in the conversation rather than the system prompt, so anything that rewrites or + * replaces the conversation can remove it. The delivery record is therefore reset whenever that happens, and the + * guidance is delivered again the next time one of its files is touched. + */ +export default function (pi: ExtensionAPI): void { + if (standsDown(isPackagedExtension, process.cwd())) return; + + const deliveredRules = new Set(); + const hintedSkills = new Set(); + const readSkills = new Set(); + // Preloaded skills live in the system prompt, which compaction leaves alone, so this is replaced at each + // agent start rather than reset with the conversation. + let preloadedSkills = new Set(); + let loadedSkills: LoadedSkill[] | undefined; + let loadedKey: string | undefined; + let triggers: SkillTrigger[] | undefined; + + const availableTriggers = (cwd: string): SkillTrigger[] => triggers ??= skillTriggers(loadedSkills, cwd); + + // Compaction summarizes the conversation, which can drop an injected rule or hint while leaving the + // delivery record claiming it is present; a switch replaces the conversation outright. + const reset = () => { + deliveredRules.clear(); + hintedSkills.clear(); + readSkills.clear(); + triggers = undefined; + }; + pi.on('session_start', reset); + pi.on('session_compact', reset); + pi.on('session_before_switch', reset); + + // Only observes which skills are in context for this session; the system prompt is never changed. + pi.on('before_agent_start', event => { + const observed = event as { systemPrompt?: unknown; prompt?: unknown; systemPromptOptions?: { skills?: LoadedSkill[] } }; + preloadedSkills = preloadedSkillNames(observed.systemPrompt); + expandedSkillNames(observed.prompt).forEach(name => readSkills.add(name)); + const skills = observed.systemPromptOptions?.skills; + const key = skills?.map(skill => `${skill.name}\t${skill.filePath}`).join('\n'); + if (key !== loadedKey) triggers = undefined; + loadedSkills = skills; + loadedKey = key; + return undefined; + }); + + pi.on('tool_result', (event, context) => { + if (event.isError) return; + const cwd = context.cwd; + const toolName: string = event.toolName; + const input = event.input; + + const reads = skillsRead(toolName, input, availableTriggers(cwd)); + reads.forEach(skill => readSkills.add(skill.name)); + + const pendingRules: ManagedRule[] = []; + const matchedFiles: string[] = []; + const hintedFiles: string[] = []; + const pendingMatches: SkillMatch[] = []; + for (const path of touchedPaths(toolName, input, cwd)) { + const relativePath = repositoryRelative(cwd, path); + if (!relativePath) continue; + for (const rule of rulesForPath(cwd, relativePath)) { + if (deliveredRules.has(rule.name) || pendingRules.some(candidate => candidate.name === rule.name)) continue; + pendingRules.push(rule); + if (!matchedFiles.includes(relativePath)) matchedFiles.push(relativePath); + } + if (toolName !== ToolName.Write && toolName !== ToolName.Edit) continue; + for (const match of skillsForPath(availableTriggers(cwd), relativePath)) { + const name = match.skill.name; + if (hintedSkills.has(name) || readSkills.has(name) || preloadedSkills.has(name)) continue; + hintedSkills.add(name); + pendingMatches.push(match); + if (!hintedFiles.includes(relativePath)) hintedFiles.push(relativePath); + } + } + if (pendingRules.length === 0 && pendingMatches.length === 0) return; + pendingRules.forEach(rule => deliveredRules.add(rule.name)); + + const existing = Array.isArray(event.content) ? event.content : []; + const text = [ + pendingRules.length === 0 + ? undefined + : `\n\n[cratis-rules] Rules that apply to ${matchedFiles.join(', ')}:\n\n${pendingRules.map(rule => rule.content).join('\n\n')}`, + pendingMatches.length === 0 ? undefined : `\n\n${hintLine(cwd, hintedFiles, pendingMatches)}`, + ].filter(part => part !== undefined).join(''); + return { content: [...existing, { type: 'text', text }] }; + }); +} diff --git a/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/skillReads.ts b/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/skillReads.ts new file mode 100644 index 00000000..284af20d --- /dev/null +++ b/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/skillReads.ts @@ -0,0 +1,63 @@ +// cratis-ai-managed: harnesses/pi/extensions/cratis-path-guidance/skillReads.ts +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { SkillTrigger } from './SkillTrigger.ts'; +import { ToolName } from './ToolName.ts'; +import { commandOf, isShellTool } from './touchedPaths.ts'; + +/** Shell commands that put a file's content in front of the model. `ls`, `git diff` or `git add` do not. */ +const readers = new Set(['cat', 'sed', 'head', 'tail', 'less', 'bat', 'rg', 'grep']); + +/** `rtk` verbs that read: `rtk read`, `rtk cat`, and `rtk grep`/`rtk rg` on a file. */ +const rtkReaders = new Set(['read', ...readers]); + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** Whether a path names a file inside the skill's directory. */ +function isInsideSkill(path: string, skill: SkillTrigger): boolean { + if (path.includes(`${skill.baseDir}/`)) return true; + return new RegExp(`(?:^|[/\\s'"=])skills/${escapeRegExp(skill.name)}/`).test(path.replaceAll('\\', '/')); +} + +/** Whether text names the skill's `SKILL.md`, or a file under its `references/`. */ +function namesSkillDocument(text: string, skill: SkillTrigger): boolean { + const normalized = text.replaceAll('\\', '/'); + if (normalized.includes(`${skill.baseDir}/SKILL.md`) || normalized.includes(`${skill.baseDir}/references/`)) return true; + return new RegExp(`(?:^|[/\\s'"=])skills/${escapeRegExp(skill.name)}/(?:SKILL\\.md|references/)`).test(normalized); +} + +/** + * The commands of a shell line that read a file. The line is split at `&&`, `||`, `|`, `;` and newlines, and a + * command counts when its verb (after an optional `rtk` or `rtk proxy`) is a reader. An `echo`, a commit message + * or `git diff` that merely mentions a path is not a read. + */ +function readerCommands(command: string): string[] { + return command.split(/\s*(?:&&|\|\||\||;(?=\s|$)|\n)\s*/).filter(segment => { + const words = segment.trim().split(/\s+/); + if (words[0] !== 'rtk') return readers.has(words[0]); + const verb = words[1] === 'proxy' ? words[2] : words[1]; + return words[1] === 'proxy' ? readers.has(verb) : rtkReaders.has(verb); + }); +} + +/** + * The skills a successful tool call read: the `read` tool on any file under `skills//`, or a shell reader + * (`cat`, `sed`, `head`, `tail`, `less`, `bat`, `rg`, `grep`, `rtk read`) on that skill's `SKILL.md` or a file + * under its `references/`. A model that opened either already has the skill's guidance. Listing the directory, + * or a `git` command that names the path, gives it nothing and is not a read. + */ +export function skillsRead(toolName: string, input: unknown, skills: SkillTrigger[]): SkillTrigger[] { + if (toolName === ToolName.Read) { + const candidate = input as { path?: unknown; file_path?: unknown } | undefined; + const value = candidate?.path ?? candidate?.file_path; + return typeof value === 'string' ? skills.filter(skill => isInsideSkill(value, skill)) : []; + } + if (!isShellTool(toolName)) return []; + const command = commandOf(input); + if (command === undefined) return []; + const commands = readerCommands(command); + return skills.filter(skill => commands.some(segment => namesSkillDocument(segment, skill))); +} diff --git a/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/skills.ts b/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/skills.ts new file mode 100644 index 00000000..4d3ea2ff --- /dev/null +++ b/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/skills.ts @@ -0,0 +1,73 @@ +// cratis-ai-managed: harnesses/pi/extensions/cratis-path-guidance/skills.ts +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { corpusRoot } from '../shared/corpusRoot.ts'; +import { frontmatter } from '../shared/frontmatter.ts'; +import { globToRegExp } from '../shared/globs.ts'; +import { skillTriggerKey } from '../shared/skillFrontmatter.ts'; +import { selectedSkillNames } from '../shared/skillSelection.ts'; +import type { LoadedSkill } from './LoadedSkill.ts'; +import type { SkillMatch } from './SkillMatch.ts'; +import type { SkillTrigger } from './SkillTrigger.ts'; + +function skillAt(root: string, name: string): LoadedSkill[] { + const filePath = join(root, name, 'SKILL.md'); + return existsSync(filePath) ? [{ name, filePath, baseDir: join(root, name) }] : []; +} + +/** + * The skills the repository selected, whether or not Pi loaded them into the session. A managed installation resolves + * them into `.cratis/ai/skills`. Without one, the packaged corpus holds every catalog skill, so the selection + * comes from `.cratis/ai.json` and the profile catalog; when that cannot be resolved nothing is hinted. + */ +function repositorySkills(cwd: string): LoadedSkill[] { + const managedRoot = join(cwd, '.cratis', 'ai', 'skills'); + if (existsSync(managedRoot)) { + return readdirSync(managedRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .flatMap(entry => skillAt(managedRoot, entry.name)); + } + const packagedRoot = join(corpusRoot, 'skills'); + return (selectedSkillNames(cwd) ?? []).flatMap(name => skillAt(packagedRoot, name)); +} + +function triggerGlobs(filePath: string): string[] { + try { + return frontmatter(readFileSync(filePath, 'utf8')).get(skillTriggerKey) ?? []; + } catch { + return []; + } +} + +/** + * The skills available to a session with their trigger globs: the skills Pi loaded for the session + * (`systemPromptOptions.skills`) together with the repository's selected skills, deduplicated by name with the + * loaded one winning. The union matters because Pi's list says nothing about whether the Cratis skills are in it: + * it can be empty (a pi-subagents agent with `skills: false`, the usual setup for cheap workers), or non-empty + * with only personal skills (an agent with `skills: true` whose `extensions:` allowlist leaves `@cratis/pi`, and + * so its skill paths, out). The selected skills' `SKILL.md` can still be read by path, which is what the hint asks + * for; a corpus skill the repository did not select is never added. Skills without a `cratis-hint-paths` trigger + * are left out. + */ +export function skillTriggers(loaded: LoadedSkill[] | undefined, cwd: string): SkillTrigger[] { + const loadedNames = new Set((loaded ?? []).map(skill => skill.name)); + return [...(loaded ?? []), ...repositorySkills(cwd).filter(skill => !loadedNames.has(skill.name))] + .map(skill => ({ + name: skill.name, + filePath: skill.filePath, + baseDir: skill.baseDir ?? dirname(skill.filePath), + globs: triggerGlobs(skill.filePath), + })) + .filter(skill => skill.globs.length > 0); +} + +/** The skills whose trigger matches a repository-relative path, each with the first glob that matched. */ +export function skillsForPath(triggers: SkillTrigger[], relativePath: string): SkillMatch[] { + return triggers.flatMap(skill => { + const glob = skill.globs.find(candidate => globToRegExp(candidate).test(relativePath)); + return glob === undefined ? [] : [{ skill, glob }]; + }); +} diff --git a/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/standDown.ts b/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/standDown.ts new file mode 100644 index 00000000..5e26e7c9 --- /dev/null +++ b/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/standDown.ts @@ -0,0 +1,49 @@ +// cratis-ai-managed: harnesses/pi/extensions/cratis-path-guidance/standDown.ts +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Whether a managed `cratis-rules` delivers path-scoped rules itself, which is every generation but the current one: + * + * - `6b0bb54`, `5b048c6`: `before_agent_start` concatenates every rule into the system prompt; + * - `aca9c6b`, `59362cf`, `1937b06`, `eec744a`: universal rules in the system prompt, path-scoped rules on + * `tool_result`; + * - current: universal rules only, taken from `../shared/rules.ts`, with no `tool_result` handler. + * + * Only a file that is recognisably the current one, importing the shared rules and having no `tool_result` + * handler, is trusted not to deliver path rules; anything else, including a file that cannot be read, is assumed + * to. Standing down on a guess costs allowlisted subagents their path guidance until `cratis ai update`, which + * is cheaper than duplicating every path-scoped rule in every full session. + */ +function managedRulesDeliverPaths(cwd: string): boolean { + const path = join(cwd, '.pi', 'extensions', 'cratis-rules', 'index.ts'); + if (!existsSync(path)) return false; + try { + const content = readFileSync(path, 'utf8'); + return !content.includes("'../shared/rules.ts'") || content.includes('tool_result'); + } catch { + return true; + } +} + +/** + * A managed installation (`.cratis/ai.manifest.json`) loads its own extensions from `.pi/extensions`. The copy + * shipped in `@cratis/pi` stands down when that installation already delivers path guidance, so a rule or hint + * is never delivered twice, whichever versions are mixed: + * + * - the managed `cratis-path-guidance` exists: it delivers rules and hints; + * - an older managed `cratis-rules` exists, which delivers path-scoped rules itself, in the system prompt or on + * `tool_result`: the packaged copy would repeat every rule, so it yields until `cratis ai update` installs the + * managed copy; + * - a current managed `cratis-rules` that only injects universal rules, or no managed Pi extensions at all: + * nothing else delivers path guidance, so the packaged copy stays active. + * + * The managed copy never stands down. + */ +export function standsDown(packaged: boolean, cwd: string): boolean { + if (!packaged || !existsSync(join(cwd, '.cratis', 'ai.manifest.json'))) return false; + return existsSync(join(cwd, '.pi', 'extensions', 'cratis-path-guidance', 'index.ts')) || managedRulesDeliverPaths(cwd); +} diff --git a/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/touchedPaths.ts b/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/touchedPaths.ts new file mode 100644 index 00000000..f4590a86 --- /dev/null +++ b/.cratis/ai/harnesses/pi/extensions/cratis-path-guidance/touchedPaths.ts @@ -0,0 +1,53 @@ +// cratis-ai-managed: harnesses/pi/extensions/cratis-path-guidance/touchedPaths.ts +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { statSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { ToolName } from './ToolName.ts'; + +/** Upper bound on files considered from one bash command, so a wide command cannot deliver the corpus. */ +const maxPathsPerCommand = 10; + +export function isShellTool(toolName: string): boolean { + return toolName === ToolName.Bash || toolName === ToolName.PowerShell; +} + +export function commandOf(input: unknown): string | undefined { + const command = (input as { command?: unknown } | undefined)?.command; + return typeof command === 'string' ? command : undefined; +} + +/** + * Extracts file paths a bash command refers to. `rtk.md` tells agents to run `rtk read`, `rtk grep` + * and `rtk find` from the terminal for bulk reads, so file access frequently arrives as a bash + * command rather than the read tool; without this, a session that follows that rule would never + * receive a path-scoped rule. A token counts only when it resolves to a file that exists inside the + * working directory, which keeps a filename mentioned inside a commit message from matching. + */ +function pathsFromCommand(command: string, cwd: string): string[] { + const found: string[] = []; + for (const raw of command.split(/[\s;|&()<>]+/)) { + if (found.length >= maxPathsPerCommand) break; + const token = raw.replace(/^['"]+|['"]+$/g, '').replace(/[,:]+$/, ''); + if (!token || token.startsWith('-') || !/[./]/.test(token)) continue; + try { + if (statSync(resolve(cwd, token)).isFile()) found.push(token); + } catch { + /* not a path we can see; ignore */ + } + } + return found; +} + +/** The file paths a tool call refers to, as the tool received them. */ +export function touchedPaths(toolName: string, input: unknown, cwd: string): string[] { + if (isShellTool(toolName)) { + const command = commandOf(input); + return command === undefined ? [] : pathsFromCommand(command, cwd); + } + if (toolName !== ToolName.Read && toolName !== ToolName.Write && toolName !== ToolName.Edit) return []; + const candidate = (input as { path?: unknown; file_path?: unknown } | undefined); + const value = candidate?.path ?? candidate?.file_path; + return typeof value === 'string' && value.length > 0 ? [value] : []; +} diff --git a/.cratis/ai/harnesses/pi/extensions/cratis-rules/index.ts b/.cratis/ai/harnesses/pi/extensions/cratis-rules/index.ts index 91992b3e..cd94f852 100644 --- a/.cratis/ai/harnesses/pi/extensions/cratis-rules/index.ts +++ b/.cratis/ai/harnesses/pi/extensions/cratis-rules/index.ts @@ -2,250 +2,19 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; -import { dirname, join, relative, resolve, sep } from 'node:path'; -import { fileURLToPath } from 'node:url'; import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; - -const corpusRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..'); - -type AiConfiguration = { profiles?: string[] }; - -export interface ManagedRule { - /** Path relative to the rules root, e.g. `code-quality-csharp.md` or `project/running-the-local-stack.md`. */ - name: string; - /** Full file content, frontmatter included, exactly as the other harnesses receive it. */ - content: string; - /** `application`, `framework`, or undefined when the rule is not profile-specific. */ - profile?: string; - /** Globs from `applyTo` and `paths`. Empty means the rule applies to every file. */ - globs: string[]; -} - -const universalGlobs = new Set(['**', '**/*', '*']); - -function unquote(value: string): string { - return value.trim().replace(/^["']|["']$/g, ''); -} - -/** - * Reads the YAML-ish frontmatter the corpus uses: scalar `key: value` lines and `key:` followed by - * ` - item` lines. Anything richer is not used by the rules and is deliberately not supported. - */ -function frontmatter(content: string): Map { - const fields = new Map(); - if (!content.startsWith('---\n')) return fields; - const end = content.indexOf('\n---\n', 4); - if (end < 0) return fields; - let current: string | undefined; - for (const line of content.slice(4, end).split('\n')) { - const item = /^\s+-\s+(.*)$/.exec(line); - if (item && current) { - fields.get(current)!.push(unquote(item[1])); - continue; - } - const scalar = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line); - if (!scalar) continue; - current = scalar[1]; - const value = unquote(scalar[2]); - fields.set(current, value ? value.split(',').map(unquote).filter(Boolean) : []); - } - return fields; -} - -function escapeRegExp(text: string): string { - return text.replace(/[.+^$()|[\]\\]/g, '\\$&'); -} - -/** Converts the glob dialect used by `applyTo` (`**`, `*`, `?`, `{a,b}`) into an anchored RegExp. */ -export function globToRegExp(glob: string): RegExp { - let pattern = ''; - for (let index = 0; index < glob.length; index++) { - const character = glob[index]; - if (character === '*') { - if (glob[index + 1] === '*') { - if (glob[index + 2] === '/') { - pattern += '(?:.*/)?'; - index += 2; - } else { - pattern += '.*'; - index += 1; - } - } else { - pattern += '[^/]*'; - } - } else if (character === '?') { - pattern += '[^/]'; - } else if (character === '{') { - const close = glob.indexOf('}', index); - if (close > index) { - pattern += `(?:${glob.slice(index + 1, close).split(',').map(part => escapeRegExp(part.trim())).join('|')})`; - index = close; - } else { - pattern += '\\{'; - } - } else { - pattern += escapeRegExp(character); - } - } - return new RegExp(`^${pattern}$`); -} - -function rulesRoot(cwd: string): string { - const managedRoot = join(cwd, '.cratis', 'ai', 'rules'); - return existsSync(managedRoot) ? managedRoot : join(corpusRoot, 'rules'); -} - -function configuration(cwd: string): AiConfiguration | undefined { - const path = join(cwd, '.cratis', 'ai.json'); - if (!existsSync(path)) return undefined; - try { - return JSON.parse(readFileSync(path, 'utf8')) as AiConfiguration; - } catch { - return undefined; - } -} - -/** - * A profile-specific rule is kept only when the repository selects that profile. Repositories without - * a `.cratis/ai.json` keep every rule, matching the `@cratis/pi` package. - */ -function matchesProfile(rule: ManagedRule, selected: AiConfiguration | undefined): boolean { - if (!rule.profile || !selected) return true; - const profiles = selected.profiles ?? []; - if (rule.profile === 'application') return profiles.some(profile => profile.startsWith('cratis/application')); - if (rule.profile === 'framework') return profiles.some(profile => profile.startsWith('cratis/engineering')); - return true; -} - -/** Loads every managed rule with its frontmatter interpreted, filtered to the repository's profiles. */ -export function managedRules(cwd: string): ManagedRule[] { - const root = rulesRoot(cwd); - const selected = configuration(cwd); - return readdirSync(root, { recursive: true, encoding: 'utf8' }) - .filter((entry): entry is string => entry.endsWith('.md')) - .sort() - .map(entry => { - const content = readFileSync(join(root, entry), 'utf8'); - const fields = frontmatter(content); - return { - name: entry.split(sep).join('/'), - content, - profile: fields.get('profile')?.[0], - globs: [...(fields.get('applyTo') ?? []), ...(fields.get('paths') ?? [])], - } satisfies ManagedRule; - }) - .filter(rule => matchesProfile(rule, selected)); -} - -/** Rules that apply to every file. These belong in the system prompt. */ -export function universalRules(cwd: string): ManagedRule[] { - return managedRules(cwd).filter(rule => rule.globs.length === 0 || rule.globs.some(glob => universalGlobs.has(glob))); -} - -/** Rules whose `applyTo`/`paths` match a repository-relative path. These are delivered when that file is touched. */ -export function rulesForPath(cwd: string, relativePath: string): ManagedRule[] { - const normalized = relativePath.split(sep).join('/'); - return managedRules(cwd).filter(rule => - rule.globs.length > 0 && - !rule.globs.some(glob => universalGlobs.has(glob)) && - rule.globs.some(glob => globToRegExp(glob).test(normalized))); -} - -/** Upper bound on files considered from one bash command, so a wide command cannot deliver the corpus. */ -const maxPathsPerCommand = 10; +import { universalRules } from '../shared/rules.ts'; /** - * Extracts file paths a bash command refers to. `rtk.md` tells agents to run `rtk read`, `rtk grep` - * and `rtk find` from the terminal for bulk reads, so file access frequently arrives as a bash - * command rather than the read tool; without this, a session that follows that rule would never - * receive a path-scoped rule. A token counts only when it resolves to a file that exists inside the - * working directory, which keeps a filename mentioned inside a commit message from matching. - */ -function pathsFromCommand(command: string, cwd: string): string[] { - const found: string[] = []; - for (const raw of command.split(/[\s;|&()<>]+/)) { - if (found.length >= maxPathsPerCommand) break; - const token = raw.replace(/^['"]+|['"]+$/g, '').replace(/[,:]+$/, ''); - if (!token || token.startsWith('-') || !/[./]/.test(token)) continue; - try { - if (statSync(resolve(cwd, token)).isFile()) found.push(token); - } catch { - /* not a path we can see; ignore */ - } - } - return found; -} - -function touchedPaths(toolName: string, input: unknown, cwd: string): string[] { - if (toolName === 'bash' || toolName === 'powershell') { - const command = (input as { command?: unknown } | undefined)?.command; - return typeof command === 'string' ? pathsFromCommand(command, cwd) : []; - } - if (toolName !== 'read' && toolName !== 'write' && toolName !== 'edit') return []; - const candidate = (input as { path?: unknown; file_path?: unknown } | undefined); - const value = candidate?.path ?? candidate?.file_path; - return typeof value === 'string' && value.length > 0 ? [value] : []; -} - -/** - * Gives Pi the same rule semantics as the other harnesses: universal rules in the system prompt, and - * path-scoped rules attached the first time a matching file is touched in the session. Without this, - * every rule was concatenated into every turn regardless of `applyTo`, `paths`, or `profile`. - * - * Delivery happens on `tool_result`, so a rule arrives after the call that first touched its file. - * `ToolCallEventResult` carries only `block`/`reason`/`terminate`, so there is no supported way to add - * context before a tool runs. In practice a file is read before it is edited, and reads through both - * the read tool and bash are covered, so the rule is present before the edit; a file created blind by - * `write` is the residual case, and it receives the rule with that result. + * Puts the universal rules in the system prompt, giving Pi the same rule semantics as the other harnesses. + * Rules scoped by `applyTo`, `paths` or `profile` are not concatenated into every turn; the + * `cratis-path-guidance` extension attaches path-scoped rules the first time a matching file is touched. * - * A delivered rule lives in the conversation rather than the system prompt, so anything that rewrites - * or replaces the conversation can remove it. The delivery record is therefore reset whenever that - * happens, and the rule is delivered again the next time one of its files is touched. + * The universal rules are a large, fixed cost on every turn (about 26k tokens), which is why a session that + * only wants path guidance and the hooks should load `cratis-path-guidance` and `cratis-hooks` instead. */ export default function (pi: ExtensionAPI): void { - const delivered = new Set(); - - // Compaction summarizes the conversation, which can drop an injected rule while leaving the - // delivery record claiming it is present; a switch replaces the conversation outright. Without - // this reset a long session would silently lose its scoped rules and never see them again. - const reset = () => { - delivered.clear(); - }; - pi.on('session_start', reset); - pi.on('session_compact', reset); - pi.on('session_before_switch', reset); - pi.on('before_agent_start', (event, context) => ({ systemPrompt: `${event.systemPrompt}\n\n${universalRules(context.cwd).map(rule => rule.content).join('\n\n')}`, })); - - pi.on('tool_result', (event, context) => { - if (event.isError) return; - const paths = touchedPaths(event.toolName, (event as { input?: unknown }).input, context.cwd); - if (paths.length === 0) return; - const pending: ManagedRule[] = []; - const matched: string[] = []; - for (const path of paths) { - const relativePath = relative(context.cwd, resolve(context.cwd, path)); - if (!relativePath || relativePath.startsWith('..')) continue; - for (const rule of rulesForPath(context.cwd, relativePath)) { - if (delivered.has(rule.name) || pending.some(candidate => candidate.name === rule.name)) continue; - pending.push(rule); - if (!matched.includes(relativePath)) matched.push(relativePath); - } - } - if (pending.length === 0) return; - pending.forEach(rule => delivered.add(rule.name)); - const existing = Array.isArray(event.content) ? event.content : []; - return { - content: [ - ...existing, - { - type: 'text', - 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')}`, - }, - ], - }; - }); } diff --git a/.cratis/ai/harnesses/pi/extensions/shared/AiConfiguration.ts b/.cratis/ai/harnesses/pi/extensions/shared/AiConfiguration.ts new file mode 100644 index 00000000..e6e0a923 --- /dev/null +++ b/.cratis/ai/harnesses/pi/extensions/shared/AiConfiguration.ts @@ -0,0 +1,9 @@ +// cratis-ai-managed: harnesses/pi/extensions/shared/AiConfiguration.ts +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** The parts of a repository's `.cratis/ai.json` that rule and skill selection read. */ +export interface AiConfiguration { + profiles?: string[]; + languages?: string[]; +} diff --git a/.cratis/ai/harnesses/pi/extensions/shared/ManagedRule.ts b/.cratis/ai/harnesses/pi/extensions/shared/ManagedRule.ts new file mode 100644 index 00000000..3bcf2b3b --- /dev/null +++ b/.cratis/ai/harnesses/pi/extensions/shared/ManagedRule.ts @@ -0,0 +1,14 @@ +// cratis-ai-managed: harnesses/pi/extensions/shared/ManagedRule.ts +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export interface ManagedRule { + /** Path relative to the rules root, e.g. `code-quality-csharp.md` or `project/running-the-local-stack.md`. */ + name: string; + /** Full file content, frontmatter included, exactly as the other harnesses receive it. */ + content: string; + /** `application`, `framework`, or undefined when the rule is not profile-specific. */ + profile?: string; + /** Globs from `applyTo` and `paths`. Empty means the rule applies to every file. */ + globs: string[]; +} diff --git a/.cratis/ai/harnesses/pi/extensions/shared/corpusRoot.ts b/.cratis/ai/harnesses/pi/extensions/shared/corpusRoot.ts new file mode 100644 index 00000000..cb187233 --- /dev/null +++ b/.cratis/ai/harnesses/pi/extensions/shared/corpusRoot.ts @@ -0,0 +1,13 @@ +// cratis-ai-managed: harnesses/pi/extensions/shared/corpusRoot.ts +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * The corpus this extension tree ships in: `.cratis/ai` for a managed installation and the repository, + * `package/corpus` inside the published `@cratis/pi` package. Both place `harnesses/pi/extensions/shared` + * four levels below it. + */ +export const corpusRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..'); diff --git a/.cratis/ai/harnesses/pi/extensions/shared/frontmatter.ts b/.cratis/ai/harnesses/pi/extensions/shared/frontmatter.ts new file mode 100644 index 00000000..0bab1f42 --- /dev/null +++ b/.cratis/ai/harnesses/pi/extensions/shared/frontmatter.ts @@ -0,0 +1,32 @@ +// cratis-ai-managed: harnesses/pi/extensions/shared/frontmatter.ts +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +export function unquote(value: string): string { + return value.trim().replace(/^["']|["']$/g, ''); +} + +/** + * Reads the YAML-ish frontmatter the corpus uses: scalar `key: value` lines and `key:` followed by + * ` - item` lines. Anything richer is not used by the rules and skills and is deliberately not supported. + */ +export function frontmatter(content: string): Map { + const fields = new Map(); + if (!content.startsWith('---\n')) return fields; + const end = content.indexOf('\n---\n', 4); + if (end < 0) return fields; + let current: string | undefined; + for (const line of content.slice(4, end).split('\n')) { + const item = /^\s+-\s+(.*)$/.exec(line); + if (item && current) { + fields.get(current)!.push(unquote(item[1])); + continue; + } + const scalar = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line); + if (!scalar) continue; + current = scalar[1]; + const value = unquote(scalar[2]); + fields.set(current, value ? value.split(',').map(unquote).filter(Boolean) : []); + } + return fields; +} diff --git a/.cratis/ai/harnesses/pi/extensions/shared/globs.ts b/.cratis/ai/harnesses/pi/extensions/shared/globs.ts new file mode 100644 index 00000000..0d62f704 --- /dev/null +++ b/.cratis/ai/harnesses/pi/extensions/shared/globs.ts @@ -0,0 +1,63 @@ +// cratis-ai-managed: harnesses/pi/extensions/shared/globs.ts +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** Globs that match every file. A rule or skill that declares one is not scoped to a path. */ +export const universalGlobs = new Set(['**', '**/*', '*']); + +function escapeRegExp(text: string): string { + return text.replace(/[.+^$()|[\]\\]/g, '\\$&'); +} + +/** Converts the glob dialect used by `applyTo` and `paths` (`**`, `*`, `?`, `{a,b}`) into an anchored RegExp. */ +export function globToRegExp(glob: string): RegExp { + let pattern = ''; + for (let index = 0; index < glob.length; index++) { + const character = glob[index]; + if (character === '*') { + if (glob[index + 1] === '*') { + if (glob[index + 2] === '/') { + pattern += '(?:.*/)?'; + index += 2; + } else { + pattern += '.*'; + index += 1; + } + } else { + pattern += '[^/]*'; + } + } else if (character === '?') { + pattern += '[^/]'; + } else if (character === '{') { + const close = glob.indexOf('}', index); + if (close > index) { + pattern += `(?:${glob.slice(index + 1, close).split(',').map(part => escapeRegExp(part.trim())).join('|')})`; + index = close; + } else { + pattern += '\\{'; + } + } else { + pattern += escapeRegExp(character); + } + } + return new RegExp(`^${pattern}$`); +} + +/** + * Why a glob cannot scope a skill trigger, or undefined when it can. A trigger must be a non-empty, + * repository-relative, balanced glob that does not match every file, otherwise it would either never fire + * or fire on every write. + */ +export function globProblem(glob: string): string | undefined { + if (glob.trim().length === 0) return 'is empty'; + if (glob !== glob.trim()) return 'has leading or trailing whitespace'; + if (glob.startsWith('/') || glob.includes('\\')) return 'must be a repository-relative glob using forward slashes'; + if (universalGlobs.has(glob)) return 'matches every file'; + if (glob.split('{').length !== glob.split('}').length) return 'has unbalanced braces'; + try { + globToRegExp(glob); + } catch { + return 'does not compile to a matcher'; + } + return undefined; +} diff --git a/.cratis/ai/harnesses/pi/extensions/shared/rules.ts b/.cratis/ai/harnesses/pi/extensions/shared/rules.ts new file mode 100644 index 00000000..46b6e6d0 --- /dev/null +++ b/.cratis/ai/harnesses/pi/extensions/shared/rules.ts @@ -0,0 +1,99 @@ +// cratis-ai-managed: harnesses/pi/extensions/shared/rules.ts +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join, sep } from 'node:path'; +import type { AiConfiguration } from './AiConfiguration.ts'; +import { corpusRoot } from './corpusRoot.ts'; +import { frontmatter } from './frontmatter.ts'; +import { globToRegExp, universalGlobs } from './globs.ts'; +import type { ManagedRule } from './ManagedRule.ts'; + +function rulesRoot(cwd: string): string { + const managedRoot = join(cwd, '.cratis', 'ai', 'rules'); + return existsSync(managedRoot) ? managedRoot : join(corpusRoot, 'rules'); +} + +function configuration(cwd: string): AiConfiguration | undefined { + const path = join(cwd, '.cratis', 'ai.json'); + if (!existsSync(path)) return undefined; + try { + return JSON.parse(readFileSync(path, 'utf8')) as AiConfiguration; + } catch { + return undefined; + } +} + +/** + * A profile-specific rule is kept only when the repository selects that profile. Repositories without + * a `.cratis/ai.json` keep every rule, matching the `@cratis/pi` package. + */ +function matchesProfile(rule: ManagedRule, selected: AiConfiguration | undefined): boolean { + if (!rule.profile || !selected) return true; + const profiles = selected.profiles ?? []; + if (rule.profile === 'application') return profiles.some(profile => profile.startsWith('cratis/application')); + if (rule.profile === 'framework') return profiles.some(profile => profile.startsWith('cratis/engineering')); + return true; +} + +/** + * The packaged `@cratis/pi` corpus holds every rule, so the languages and documentation a repository selected + * decide which apply: a rule scoped to `.cs` files needs `csharp`, to `.ts` files needs `typescript`, and to + * Markdown needs the `cratis/documentation` profile. A repository that selects no languages keeps them all. + */ +function matchesSelection(name: string, applyTo: string[], selected: AiConfiguration | undefined): boolean { + const languages = selected?.languages ?? []; + if (!selected || languages.length === 0) return true; + const scope = applyTo.join(','); + const needsCSharp = scope.includes('.cs'); + const needsTypeScript = scope.includes('.ts') || name === 'rtk.md' || name.endsWith('/rtk.md'); + const needsDocumentation = scope.includes('md'); + if (!needsCSharp && !needsTypeScript && !needsDocumentation) return true; + return (needsCSharp && languages.includes('csharp')) || + (needsTypeScript && languages.includes('typescript')) || + (needsDocumentation && (selected.profiles ?? []).includes('cratis/documentation')); +} + +/** + * Loads every managed rule with its frontmatter interpreted, filtered to the repository's profiles. From the + * packaged corpus, which is not resolved for the repository, the selected languages and documentation apply as + * well. A managed `.cratis/ai/rules` is already resolved by the CLI, so that second filter is skipped there. + */ +export function managedRules(cwd: string): ManagedRule[] { + const root = rulesRoot(cwd); + const selected = configuration(cwd); + const resolved = existsSync(join(cwd, '.cratis', 'ai', 'rules')); + return readdirSync(root, { recursive: true, encoding: 'utf8' }) + .filter((entry): entry is string => entry.endsWith('.md')) + .sort() + .map(entry => { + const content = readFileSync(join(root, entry), 'utf8'); + const fields = frontmatter(content); + return { + rule: { + name: entry.split(sep).join('/'), + content, + profile: fields.get('profile')?.[0], + globs: [...(fields.get('applyTo') ?? []), ...(fields.get('paths') ?? [])], + } satisfies ManagedRule, + applyTo: fields.get('applyTo') ?? [], + }; + }) + .filter(({ rule, applyTo }) => matchesProfile(rule, selected) && (resolved || matchesSelection(rule.name, applyTo, selected))) + .map(({ rule }) => rule); +} + +/** Rules that apply to every file. These belong in the system prompt. */ +export function universalRules(cwd: string): ManagedRule[] { + return managedRules(cwd).filter(rule => rule.globs.length === 0 || rule.globs.some(glob => universalGlobs.has(glob))); +} + +/** Rules whose `applyTo`/`paths` match a repository-relative path. These are delivered when that file is touched. */ +export function rulesForPath(cwd: string, relativePath: string): ManagedRule[] { + const normalized = relativePath.split(sep).join('/'); + return managedRules(cwd).filter(rule => + rule.globs.length > 0 && + !rule.globs.some(glob => universalGlobs.has(glob)) && + rule.globs.some(glob => globToRegExp(glob).test(normalized))); +} diff --git a/.cratis/ai/harnesses/pi/extensions/shared/skillFrontmatter.ts b/.cratis/ai/harnesses/pi/extensions/shared/skillFrontmatter.ts new file mode 100644 index 00000000..01c056e7 --- /dev/null +++ b/.cratis/ai/harnesses/pi/extensions/shared/skillFrontmatter.ts @@ -0,0 +1,10 @@ +// cratis-ai-managed: harnesses/pi/extensions/shared/skillFrontmatter.ts +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * The `SKILL.md` frontmatter key that lists the globs a skill is hinted for. It is deliberately Cratis-specific: + * Claude Code gives a plain `paths` key in `SKILL.md` a meaning of its own (a "conditional skill" that stays out + * of the skill list until a matching file is touched), and `.claude/skills` exposes this corpus to it. + */ +export const skillTriggerKey = 'cratis-hint-paths'; diff --git a/.cratis/ai/harnesses/pi/extensions/shared/skillSelection.ts b/.cratis/ai/harnesses/pi/extensions/shared/skillSelection.ts new file mode 100644 index 00000000..9eff0ecf --- /dev/null +++ b/.cratis/ai/harnesses/pi/extensions/shared/skillSelection.ts @@ -0,0 +1,68 @@ +// cratis-ai-managed: harnesses/pi/extensions/shared/skillSelection.ts +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { AiConfiguration } from './AiConfiguration.ts'; +import { corpusRoot } from './corpusRoot.ts'; + +interface Profile { + id: string; + composes?: string[]; + availableTargets?: string[]; + languages?: string[]; +} + +interface Catalog { + publicProfiles: Profile[]; + engineeringProfiles: Profile[]; +} + +/** The catalog beside the repository corpus (`.cratis/ai`), or beside `package/corpus` in the published package. */ +function catalog(): Profile[] | undefined { + const path = [join(corpusRoot, 'profile-catalog.json'), join(corpusRoot, '..', 'profile-catalog.json')].find(existsSync); + if (path === undefined) return undefined; + const parsed = JSON.parse(readFileSync(path, 'utf8')) as Catalog; + return [...parsed.publicProfiles, ...parsed.engineeringProfiles]; +} + +function supportsLanguage(profile: Profile, languages: Set): boolean { + if (!profile.languages?.length) return true; + return profile.languages.some(language => language === 'language-agnostic' || languages.has(language)); +} + +/** + * The skills the packaged `@cratis/pi` makes available for a repository, resolved from `.cratis/ai.json` and the + * profile catalog exactly as `selectedSkillPaths` in `@cratis/pi` does (a repository without `ai.json` gets every + * catalog skill, because that is what the package loads for it). Undefined when the selection cannot be resolved, + * so a caller hints nothing rather than guessing. + * + * This mirrors `Source/Pi.Plugin/src/index.ts`, which cannot import from the corpus in both the repository and + * the published layout; a specification keeps the two in step. + */ +export function selectedSkillNames(cwd: string): string[] | undefined { + try { + const profiles = catalog(); + if (profiles === undefined) return undefined; + const configurationPath = join(cwd, '.cratis', 'ai.json'); + if (!existsSync(configurationPath)) { + return [...new Set(profiles.flatMap(profile => profile.availableTargets ?? []))].sort(); + } + const configuration = JSON.parse(readFileSync(configurationPath, 'utf8')) as AiConfiguration; + const languages = new Set(configuration.languages ?? []); + const selected = new Set(); + const select = (id: string, explicitSelection: boolean): void => { + const profile = profiles.find(candidate => candidate.id === id); + if (!profile) throw new Error(`Unknown Cratis AI profile '${id}'.`); + if (!explicitSelection && !supportsLanguage(profile, languages)) return; + if (selected.has(id)) return; + selected.add(id); + profile.composes?.forEach(child => select(child, false)); + }; + configuration.profiles?.forEach(profile => select(profile, true)); + return [...new Set(profiles.filter(profile => selected.has(profile.id)).flatMap(profile => profile.availableTargets ?? []))].sort(); + } catch { + return undefined; + } +} diff --git a/.cratis/ai/prompts/ship-changes.prompt.md b/.cratis/ai/prompts/ship-changes.prompt.md index b8117075..af6f4b86 100644 --- a/.cratis/ai/prompts/ship-changes.prompt.md +++ b/.cratis/ai/prompts/ship-changes.prompt.md @@ -2,21 +2,21 @@ agent: agent description: > Ship local changes: create a branch, make logical commits, push, open and - label a PR with a proper description, merge it, prepare no-effect related - issue dispositions, and delete the branch. + label a PR with a proper description, merge it, prepare related issue + dispositions the request does not authorize, and delete the branch. --- # Ship Changes Ship the current local modifications to `main` through the standard -branch → commits → PR → merge → no-effect issue disposition → cleanup workflow. +branch → commits → PR → merge → issue disposition → cleanup workflow. ## Inputs - **What changed** — brief description of the work (used for branch name and PR title) - **Label** — `no-release`, `patch`, `minor`, or `major`, or omit entirely if no label should be applied -- **Related issue** — optional exact repository and issue number; if unknown, search read-only first. Comment on or close it only when the user's request includes that effect. +- **Related issue** — optional exact repository and issue number; if unknown, search read-only first. Comment on or close it only when the user's request includes that effect; otherwise prepare the disposition as a proposal. In repositories released by `cratis/release-action`, a delivered issue is closed by release-action through `(#n)` in the description, but release-action does not run for a `no-release` PR, so `(#n)` closes nothing there; elsewhere follow the repository's own release and issue-closing process. Invoking this prompt is direct authority for the standard branch, commit, push, pull-request, requested-label, merge, and branch-cleanup effects. Do not pause to ask for separate approval at @@ -33,3 +33,18 @@ Every other label proceeds through merge under this prompt's ordinary authority. This prompt is for an explicit request to ship or land. If the user asked only to commit, only to push, or only to open a PR, stop after that step: the narrower request does not become authority for the rest of the chain because this prompt happens to be loaded. + +## Pull request description + +The description is published verbatim as the release notes, so it follows the contract in +[`pull-requests.md`](../rules/pull-requests.md#the-description-is-the-release-note). + +- Before `gh pr create` or `gh pr edit`, write the body from `.github/pull_request_template.md` and + check it against the contract. The rules agents most often break: + - No development write-up: no Overview/Verification/Test plan headings (only a first `## Summary` + is allowed), no review, testing or provenance notes. Those go in a PR comment. + - `(#n)` ends the bullet that delivers an issue and `(part of #n)` marks anything that stays open; + never `Closes`/`Fixes`/`Refs` before a number, and never an issue reference inside an HTML comment, + which still closes the issue. + - Links are absolute `https://` URLs, never relative paths. +- After pushing, if `verify-release-notes` fails, fix it by editing the description; it re-runs on edit. diff --git a/.cratis/ai/rules/pull-requests.md b/.cratis/ai/rules/pull-requests.md index 294f3994..fc62f950 100644 --- a/.cratis/ai/rules/pull-requests.md +++ b/.cratis/ai/rules/pull-requests.md @@ -5,19 +5,81 @@ applyTo: "**/*" # How to Do Pull Requests -PR descriptions serve two purposes: they help reviewers understand the change *now*, and they become the release notes that users read *later*. Write them with both audiences in mind. +## The description is the release note -**The description is the release note — it is published verbatim.** Write it as the note you want the person upgrading to read, in the repository template's sections. A generic development write-up (`## Summary`, `## Verification`, `## Testing`, a list of the files you touched, a description of how you arrived at the change) is not a release note, and shipping one makes the release history unreadable. The same applies wherever a release is produced by hand: release-notes text typed into a manual workflow run, or written straight into a published release, carries exactly the same shape and the same audience as a PR description. There is no path to a release whose notes are allowed to describe the work instead of the change. +**The PR description is published verbatim as the GitHub release. In repositories released by `cratis/release-action`, it also closes every issue written as `(#n)`; elsewhere, follow the repository's own release and issue-closing process.** Write the note the person upgrading should read, not a development write-up. The same contract applies to release notes typed into a manual workflow run or written straight into a release. Where it is installed, the `verify-release-notes` check enforces it on every PR into the default branch that carries exactly one of `major`/`minor`/`patch` (it re-runs when the label is added); it skips `no-release` and unlabelled PRs, which should still be written this way because any of them may become releasable. `release-action` does not run for a `no-release` PR, so `(#n)` closes nothing there. Editing the description re-runs the check. HTML comments are not shown on the release page and the check ignores them for headings, keywords and placeholders, but a `(#n)` inside a comment still closes the issue (`release-action` skips only fenced and inline code) and the check fails it, so never put an issue reference in a comment. The template's own comment can stay: its `(#123)` examples are inline code. -## Description +### Allowed shape -- Follow the repository's pull request template (`.github/pull_request_template.md`). -- Focus on the **Added**, **Changed**, **Fixed**, **Removed**, **Security**, and **Deprecated** sections. Remove sections that are empty — don't leave blank headings. -- Each bullet should be short, self-contained, and release-note ready. -- **Write for users of the framework, not for internal developers.** Only include changes that have an impact on anyone using what we build — new APIs, changed behavior, fixed bugs, removed features. Do not list internal implementation details like storage changes, converter updates, gRPC contract internals, or spec additions. If a change is purely internal plumbing, it does not belong in the PR description. For a user-visible fix, a brief root cause and what now prevents a regression, stated as observable behavior rather than a list of specs, are user-facing and may be included: they tell the upgrader whether to trust the fix. Credit an external contributor by name or handle, unless they asked not to be named. -- Add the associated issue reference at the end of a bullet when there is a real GitHub issue for the change (e.g. `(#351)`). Keep it a bare reference — **no closing keywords** (`Closes #351`, `Fixes #351`) anywhere in the body, because the published release notes are the PR description verbatim. If there is no associated issue, omit the reference entirely. Never use a placeholder like `(#issue)` or leave the example number `(#123)` literally, and never invent a random issue number. **Always verify the issue number read-only using the repository source — never guess or invent a number.** Comment on or close an issue when the user's request includes that effect; otherwise prepare a bounded post-merge disposition without performing it. -- Include a summary only if there is a cohesive theme across the changes. If you find yourself restating individual bullets in slightly different words, the summary adds no value — remove it. -- Never include Copilot prompt content in the PR description. Remove any "Original prompt" / coding agent transcript blocks before publishing. +- Follow the repository's pull request template (`.github/pull_request_template.md`) within this contract. +- An optional summary, first, only when one cohesive theme spans the bullets: **either** a `## Summary` section (short, user-facing prose) **or** one unheaded lead paragraph (1–3 sentences), never both. `Summary` is a level-2 heading; `# Summary` is the wrong level. No bullets before the first section. +- Then only these `##` sections, each at most once, in this order after any `## Summary`: `## Added`, `## Changed`, `## Fixed`, `## Removed`, `## Security`, `## Deprecated`. Keep only the sections that have bullets: a section holding only prose, a code example or `None.` fails the check, so delete it. `###` sub-headings inside a section are fine unless they use a forbidden name; inside a section even `### Added` is only a sub-heading, and a section itself is always level 2. +- A `major`/`minor`/`patch` PR needs at least one bullet under an allowed section. +- Bullets are short, self-contained and user-facing: what a consumer compiles against, runs or observes. A user-visible fix may add one sentence of root cause or regression guard, stated as observable behavior. Credit an external contributor by name or handle, unless they asked not to be named. +- Breaking changes and upgrade actions are bullets in `## Changed` or `## Removed` that state the action. A longer migration story goes in the docs, linked by absolute URL. +- Do not list internal plumbing (storage, converters, gRPC internals, specs, file lists, refactor narration). + +### Issue references + +| Write | Meaning | +| --- | --- | +| `(#351)` at the **end of the bullet that delivers it** | Delivered: in a repository released by `cratis/release-action`, it closes it at release | +| `(part of #351)` or prose `see #351` | Related, partial or follow-up: never closes | +| `Cratis/Repo#351` | Other repository: never closed | +| one `(#351)` per issue, never `(#351, #352)` | `(#351, #352)` closes nothing: write `(#351) (#352)` | + +- Nothing but more issue references (also `(part of #n)`, `(see #n)` and `(Cratis/Repo#n)`), closing emphasis, `
` and sentence punctuation (a `:` may introduce nested bullets) may follow the delivering `(#n)`. Write one `(#n)` per issue: release-action only matches `(#n)`, so `(#56, #57)` closes nothing; write `(#56) (#57)`. +- Never use a linking or closing keyword before a number, anywhere: `Close`, `Closes`, `Closed`, `Fix`, `Fixes`, `Fixed`, `Resolve`, `Resolves`, `Resolved`, `Refs`, `Ref`, `References`, also as `Keyword: #n`, with `owner/repo#n`, followed by an issue URL, and wrapped in bold, italics or a link (`**Closes** #n`, `Closes [#n](url)`, `[Closes #n](url)`, and `[Closes](url)` where the link starts a clause). A `Refs #93` line closes nothing and is not a delivery marker: write `(#93)` at the end of the delivering bullet, or `(part of #93)` if it must stay open. +- Comment on or close an issue when the user's request includes that effect; otherwise prepare a bounded post-merge disposition without performing it. In repositories released by `cratis/release-action`, a delivered `(#n)` is closed by release-action at release, so do not close it by hand; for a `no-release` PR release-action does not run, so any disposition follows this same rule. Elsewhere, follow the repository's own release and issue-closing process. +- Use a bare `(#n)` only for an issue this PR fully delivers. No issue means no reference. Never use a placeholder such as `(#issue)` or the template's `(#123)`, and verify every number exists in the right repository; never guess. + +### Forbidden anywhere outside code + +- **Headings** (any level, also written as HTML `

`, ``, `` or a possibly multi-line ``, or as an `*italic*` line; a heading line is checked like any other, so a keyword, relative link or `(#n)` in one fails too) other than a first `## Summary`, such as Overview, Description, What, Why, How, Context, Changes, What changed, Test plan, Testing, Tests, Verification, Verified, Validation, Quality, Review, Notes, Notes for reviewers, Limitations, Known follow-up, Acceptance, Details, and any `#`/`##` heading not in the allowed list. +- **Review, verification, testing and provenance notes**: `Review:`, `Reviewed:`, a stand-alone `Reviewed by` line, `Verification:`, `Tested:`, `Testing:`, `Validation:` lines that stand alone or report a result (`Tests: 400 passed`, `Review: approved`); same-provider, cross-provider, Opus-only or Anthropic-only review remarks; the review workflow having passed, returned or run; CI or gate results (`CI green`, `all tests passed`); and a line stating which agent or model wrote the description (`Generated with Claude Code`, `Co-Authored-By:`). A bullet that describes a product change and only mentions review, validation, tests or an AI model (`- Validation: rules now apply to commands (#3)`, `- Reviewed by status is now shown on the dashboard (#4)`) is fine: inside a bullet, a summary or the lead paragraph the check reads a note only when it ends the clause (`- Cross-provider review pending`). +- **Internal state** that is not a consumer change (for example "npm publication remains disabled"). +- **Relative links** (`](Documentation/x.md)`, `](./x)`, `](Source/...)`): they 404 on the release page. Use `https://github.com/Cratis//blob/main/`, a `#anchor` or `mailto:`. +- **Placeholders and transcripts**: template text, empty sections, Copilot "Original prompt" blocks, agent transcripts. + +Reviewer-facing information (test plan, verification, review provenance, notes for reviewers) goes in a **PR comment**, never the description. + +### Bad to good + +Bad (Cratis/Arc.TypeScript v0.48.0 as first published, bullets abridged): + +```markdown +Arc can now construct Chronicle reactors and reducers through its own dependency injection, one scope per delivered batch, as an opt-in preview. npm publication remains disabled. + +## Added + +- Preview option `withChronicle({ ..., activateArtifactsInScopes: true })` ... (#93) + +See [Activate reactors and reducers in Arc scopes](Documentation/chronicle/reactors/scoped-activation.md). + +Review: Opus-only (same-provider) review. + +Refs #93 +``` + +Good (as corrected): + +```markdown +Arc can now construct Chronicle reactors and reducers through its own dependency injection, one scope per delivered batch, as an opt-in preview. + +## Added + +- Preview option `withChronicle({ ..., activateArtifactsInScopes: true })` ... See [Activate reactors and reducers in Arc scopes](https://github.com/Cratis/Arc.TypeScript/blob/main/Documentation/chronicle/reactors/scoped-activation.md). (part of #93) +``` + +"npm publication remains disabled" is internal status, the link was relative (it 404s on the release page), the review line is provenance for reviewers, and `Refs #93` is a keyword line. #93 was only partly delivered by this release, so its bullets say `(part of #93)`; `(#93)` would have closed it. + +### Before you create or edit a PR + +1. Read the body against the forbidden list and the issue table above; delete every hit and verify every issue number. +2. Sections in order, none empty, at most one summary form; every bullet user-facing. +3. Test plan, verification and review notes are in a PR comment. + +If `verify-release-notes` fails, fix it by editing the description, not by pushing code. ## Commits diff --git a/.cratis/ai/skills/cratis-application-react-specifications/SKILL.md b/.cratis/ai/skills/cratis-application-react-specifications/SKILL.md index b4102296..5345c40a 100644 --- a/.cratis/ai/skills/cratis-application-react-specifications/SKILL.md +++ b/.cratis/ai/skills/cratis-application-react-specifications/SKILL.md @@ -2,6 +2,9 @@ name: cratis-application-react-specifications 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. license: MIT +cratis-hint-paths: + - "**/for_*/**/*.ts" + - "**/for_*/**/*.tsx" --- diff --git a/.cratis/ai/skills/cratis-documentation-writing/SKILL.md b/.cratis/ai/skills/cratis-documentation-writing/SKILL.md index 58d06613..0cbc67e4 100644 --- a/.cratis/ai/skills/cratis-documentation-writing/SKILL.md +++ b/.cratis/ai/skills/cratis-documentation-writing/SKILL.md @@ -2,6 +2,8 @@ name: cratis-documentation-writing 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. license: MIT +cratis-hint-paths: + - "**/Documentation/**/*.{md,mdx}" --- @@ -62,7 +64,13 @@ and neighboring pages; ask only when the alternatives change the outcome. targeted recipes, and exact reference. Give different languages or hosts their own procedures when a shared path cannot be run as written. A 'coming from X' bridge should map familiar concepts to the new workflow, - not replace it. + not replace it. When a page shows several backends, state how they differ + once, in a "how the backends differ" table near the start: identity + source, id and response wire types, default authorization, live versus + snapshot queries, and unsupported features with their tracking issue. + Inside a step, write only what the current tab's reader needs. Across a + series, keep backend limitations in one table on the series index and link + it; don't repeat an issue-tracked limitation on every page. 4. Draft in workflow order. For a tutorial, use one working domain throughout, show what to run and what appears, and recap before adding another concept. For a how-to, keep only what the specific task needs. Link out for details. @@ -129,9 +137,9 @@ An AI-drafted narrative is a first draft, not a finished page. Give the model the reader, the scenario, the terminology and the source evidence up front; then revise the result against that evidence and the **cratis-writing-voice-and-cadence** constructions before it ships. Tell the -reviewer the narrative was AI-drafted (in the review request or commit -message, not in a PR description's release-note sections) so they read it as -prose, not only as a diff. +reviewer the narrative was AI-drafted (in the review request, a PR comment or +the commit message, never in the PR description, which is published as the +release note) so they read it as prose, not only as a diff. For machine-readable delivery and retrieval checks, use **cratis-llm-friendly-documentation**; publishing `llms-full.txt` alone does @@ -145,6 +153,12 @@ Never transcribe an API from memory, hand-translate an unsupported client, or claim a pasted block is runnable when it requires unstated setup. Show the command and observable output for a substantial walkthrough. +Adding a backend or client to a product also changes pages the product's own +checks don't scan, such as cross-product and site-level pages. Search them for +single-language claims ("in C#", C#-only APIs presented as universal) and for +series indexes whose description, reading order or production claims no longer +hold. + Edit the *authored* file, never a synced copy. The Cratis site derives each product page's edit link from that source path, so don't hand-author `editUrl` in product frontmatter. Recheck examples against the supported @@ -158,8 +172,9 @@ wording, structure and examples as readers hit them. Read [Cratis site specifics](references/cratis-site.md) before writing a Cratis product page. It covers the teaching components (`YouWillLearn`, -`Recap`, client tabs), maturity labeling, cross-product compatibility, the -Prompter feedback signal, and who decides page structure. +`Recap`, client tabs, including on site-owned pages), the variant-docs audit, +maturity and preview labeling, cross-product compatibility, the Prompter +feedback signal, and who decides page structure. ## Contextual awareness diff --git a/.cratis/ai/skills/cratis-documentation-writing/references/cratis-site.md b/.cratis/ai/skills/cratis-documentation-writing/references/cratis-site.md index 5ee97882..188bb389 100644 --- a/.cratis/ai/skills/cratis-documentation-writing/references/cratis-site.md +++ b/.cratis/ai/skills/cratis-documentation-writing/references/cratis-site.md @@ -26,6 +26,16 @@ imports and JSX. Use one where it clearly teaches better than plain Markdown. - The site links each expanded tab to its exact snippet file in the owning repository. Don't add those links by hand. - Arc's authoring check rejects `ArcBackendTabs` nested inside `Steps`. + Tabs can't sit inside `Steps` anywhere, so write a backend procedure as + numbered H2 steps and keep `Steps` for shared, untabbed procedures such as + the React screen. +- Site-owned MDX under `web/src/content/docs/` can use `` and + `` without an import. The site expands them from the + same manifest and client-owned snippets as product pages, including in the + Copy Markdown mirror; check the mirror to confirm the macro expanded. +- The variant-docs audit (`web/variant-docs.yml`) also covers site-owned + pages. Every site page is audited or explicitly excluded with a reason, and + direct single-language backend fences are ratcheted. - `TopicHero` declares `title` (required), `icon` and `eyebrow`. Don't copy props from a page that passes others; they are ignored. @@ -41,6 +51,10 @@ example in a `:::caution` aside, and state only the change and production limits the product source supports. Don't infer those limits from a site-wide label. +Label a preview variant on the first screen of every page that shows it, not +only on its own getting-started page. An unsupported-variant snippet is one +sentence with its tracking issue; the surrounding prose doesn't repeat it. + ## Cross-product compatibility There is no numbered compatibility matrix across the Chronicle kernel and its diff --git a/.cratis/ai/skills/cratis-engineering-docs-authoring/SKILL.md b/.cratis/ai/skills/cratis-engineering-docs-authoring/SKILL.md index 51d50f3e..06aecf2e 100644 --- a/.cratis/ai/skills/cratis-engineering-docs-authoring/SKILL.md +++ b/.cratis/ai/skills/cratis-engineering-docs-authoring/SKILL.md @@ -2,6 +2,8 @@ name: cratis-engineering-docs-authoring 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. license: LICENSE +cratis-hint-paths: + - "**/Documentation/**/*.{md,mdx}" --- diff --git a/.cratis/ai/skills/cratis-release-notes/SKILL.md b/.cratis/ai/skills/cratis-release-notes/SKILL.md index 22dcb7d6..5d3c21bc 100644 --- a/.cratis/ai/skills/cratis-release-notes/SKILL.md +++ b/.cratis/ai/skills/cratis-release-notes/SKILL.md @@ -21,10 +21,10 @@ required to draft notes. Before writing, identify product, tag or candidate version, previous version, artifact/package names, supported platforms, and the audience. Inventory observable changes from the diff, specs, linked issues/PRs, and authoritative -product source at that revision. A PR description may be published verbatim as -release notes in a Cratis repository; follow its current template and confirm -that behavior before drafting. Do not turn an unreleased draft into a claim -about shipped behavior. +product source at that revision. A PR description is published verbatim as +release notes in a Cratis repository; follow its current template and the +pull-request contract, and confirm that behavior before drafting. Do not turn +an unreleased draft into a claim about shipped behavior. For each change, ask: who uses this path, what happened before, what happens now, is action required, and how would they notice? Check changed defaults, @@ -43,15 +43,29 @@ one to add weight. ## Write for the right channel -- **Exact-version release note (GitHub/PR):** start with changes requiring - action and affected workflows, then new capabilities and fixes. State impact - and the user's next action in plain language. Include an issue reference only - when verified, and follow the owning repository's closing-keyword policy. - Do not list internal refactors or specs that change nothing users observe. - For a user-visible fix, a sentence of root cause and of what now guards - against a regression, stated as observable behavior rather than a list of - specs, is user-facing: it tells the reader whether to trust the fix. Credit an external contributor by name or handle and say what - they did, unless they asked not to be named. +- **Exact-version release note (GitHub/PR):** in a Cratis repository this is + the merged PR description, published verbatim, so it follows the contract in + [pull-requests.md](../../rules/pull-requests.md), which holds the full + rules. The ones most often broken: no development write-up (no Overview, + Verification or Test plan headings, no review or provenance notes; those go + in a PR comment); `(#n)` at the end of the bullet that delivers an issue and + `(part of #n)` for anything that stays open (in repositories released by + `cratis/release-action`, `(#n)` is what closes the issue), never `Closes`, `Fixes` or + `Refs` before a number (also in bold or a link), and no issue reference + inside an HTML comment, which still closes the issue; and absolute + `https://` links only. Sections are in + a fixed order (Added, Changed, Fixed, Removed, Security, Deprecated); within + a section, put changes requiring action first, then new capabilities and + fixes. State impact and the user's next action in plain language: state an + upgrade action in its `## Changed` or `## Removed` bullet (a first + `## Summary` section or an unheaded lead paragraph, not both, may mention it + too). Do not list internal + refactors or specs that change nothing users observe. For a + user-visible fix, a sentence of root cause and of what now guards against a + regression, stated as observable behavior rather than a list of specs, is + user-facing: it tells the reader whether to trust the fix. Credit an + external contributor by name or handle and say what they did, unless they + asked not to be named. - **Migration guide (durable product docs):** a compact *old behavior → new behavior → required action* table for each affected upgrade path, followed by source-verified before/after code or commands. Distinguish required @@ -76,6 +90,23 @@ boundary that makes a change safe to adopt. One item can follow this shape: That is a checklist for facts, not a template to repeat word-for-word across items. Use descriptive headings, meaningful links, and natural sentence rhythm. +## Repair an already-published release + +Only when asked to fix a release that violates the contract: + +1. Read the published body (`gh release view TAG --json body`) and the merged + PR. Keep the facts; remove headings, review and verification lines, + internal status and closing keywords, and turn relative links into + `https://` URLs. Do not invent changes, versions or issue numbers. +2. Publish the corrected text with `gh release edit TAG --notes-file FILE`, + only when the request authorizes editing that release. +3. In a repository released by `cratis/release-action`, editing a release + does not re-run release-action, so an issue that a `(#n)` would have + closed stays open. For each one, confirm the release + actually delivered it. Close it by hand with a comment naming the release + only when the request covers closing issues; otherwise list the issues as a + proposal. Leave an issue open when the release only partly delivered it. + ## Verify before handing over 1. Reconcile every version, affected range, capability and API against the diff --git a/.cratis/ai/skills/cratis-specifications-typescript/SKILL.md b/.cratis/ai/skills/cratis-specifications-typescript/SKILL.md index 2626a438..dcdcfd50 100644 --- a/.cratis/ai/skills/cratis-specifications-typescript/SKILL.md +++ b/.cratis/ai/skills/cratis-specifications-typescript/SKILL.md @@ -2,6 +2,9 @@ name: cratis-specifications-typescript 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. license: MIT +cratis-hint-paths: + - "**/for_*/**/*.ts" + - "**/for_*/**/*.tsx" --- diff --git a/.cratis/ai/skills/cratis-technical-examples/SKILL.md b/.cratis/ai/skills/cratis-technical-examples/SKILL.md index 645f9809..9bf2c1fd 100644 --- a/.cratis/ai/skills/cratis-technical-examples/SKILL.md +++ b/.cratis/ai/skills/cratis-technical-examples/SKILL.md @@ -2,6 +2,8 @@ name: cratis-technical-examples 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. license: MIT +cratis-hint-paths: + - "**/Samples/**/*.{cs,ts,tsx}" --- @@ -32,6 +34,11 @@ language-tab layout or documentation site implementation. - **Multi-language client example:** keep one explanation; use the established client-owned snippet mechanism, compile each client against its own SDK, and offer only implementations that exist. Do not translate a C# call by guess. + Use the same domain type names in every tab; if the prose has to explain + which name each tab uses, fix the snippets instead. State each backend's id + and command-response wire type (for example `Guid` versus `string`) and keep + one id type per concept across every page of a series. Shared frontend code + that depends on those types must be backend-neutral or tabbed per backend. - **Sample application:** one realistic domain, explicit prerequisites and package versions, a documented run command, a reproducible state to start from, and a visible result. Keep it minimal enough for a new reader to finish. @@ -84,6 +91,14 @@ elsewhere in product documentation. For those, verify against real source and a runnable sample/spec or an explicit snippet comparison; do not claim coverage from a site build or a validator that never scans the page. +A compiling tab is not a working tab. For each backend, confirm that the +artifact is discovered and registered, that proxy generation emits the file +and types the page names, and that any runtime behavior the page claims (live +updates, constraint rejection) was observed against a real kernel and sink. If +a tab's code compiles but fails at runtime, don't publish it with a workaround +in the prose after it. Put working code in the tab and reference the tracking +issue in a code comment. + A simple process beats a large unmaintained examples gallery: give the reader one small success first, then link to a fuller sample when they need it. Update examples alongside public API changes and incoming reports of copy/paste failure.