Files
oh-my-pi/packages/coding-agent/src/capability/rule.ts
T
can1357 149f787fae feat(coding-agent): enabled per-rule interrupt mode overrides via frontmatter
- Added per-rule `interruptMode` override capability to TTSR interrupt logic via optional frontmatter field.
- Changed interrupt behavior to respect per-rule `interruptMode` settings with fallback to global `ttsr.interruptMode` configuration.
- Extended `Rule` and `RuleConfig` interfaces with optional `interruptMode` property for granular control.
- Updated rule discovery to extract and validate `interruptMode` from frontmatter with proper enum type checking.
2026-03-14 14:13:09 +01:00

224 lines
5.9 KiB
TypeScript

/**
* Rules Capability
*
* Project-specific rules from Cursor (.mdc), Windsurf (.md), and Cline formats.
* Translated to a canonical shape regardless of source format.
*/
import { defineCapability } from ".";
import type { SourceMeta } from "./types";
const CONDITION_GLOB_SCOPE_TOOLS = ["edit", "write"] as const;
/**
* Parsed frontmatter from rule files.
*/
export interface RuleFrontmatter {
description?: string;
globs?: string[];
alwaysApply?: boolean;
/** New key for TTSR match conditions. */
condition?: string | string[];
/** New key for TTSR stream scope. */
scope?: string | string[];
/** Per-rule TTSR interrupt mode override. */
interruptMode?: "never" | "prose-only" | "tool-only" | "always";
[key: string]: unknown;
}
/**
* A rule providing project-specific guidance and constraints.
*/
export interface Rule {
/** Rule name (derived from filename) */
name: string;
/** Absolute path to rule file */
path: string;
/** Rule content (after frontmatter stripped) */
content: string;
/** Globs this rule applies to (if any) */
globs?: string[];
/** Whether to always include this rule */
alwaysApply?: boolean;
/** Description (for agent-requested rules) */
description?: string;
/** Regex condition(s) that can trigger TTSR interruption. */
condition?: string[];
/** Optional stream scope tokens (for example: text, thinking, tool:edit(*.ts)). */
scope?: string[];
/** Per-rule TTSR interrupt mode override (falls back to global ttsr.interruptMode). */
interruptMode?: "never" | "prose-only" | "tool-only" | "always";
/** Source metadata */
_source: SourceMeta;
}
function normalizeRuleField(value: unknown): string[] | undefined {
if (typeof value === "string") {
const token = value.trim();
return token.length > 0 ? [token] : undefined;
}
if (!Array.isArray(value)) {
return undefined;
}
const tokens = value
.filter((item): item is string => typeof item === "string")
.map(item => item.trim())
.filter(item => item.length > 0);
if (tokens.length === 0) {
return undefined;
}
return Array.from(new Set(tokens));
}
function splitScopeTokens(value: string): string[] {
const tokens: string[] = [];
let current = "";
let parenDepth = 0;
let bracketDepth = 0;
let braceDepth = 0;
let quote: '"' | "'" | undefined;
for (let i = 0; i < value.length; i++) {
const char = value[i];
if (quote) {
current += char;
if (char === quote && value[i - 1] !== "\\") {
quote = undefined;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === "(") {
parenDepth++;
current += char;
continue;
}
if (char === ")") {
parenDepth = Math.max(0, parenDepth - 1);
current += char;
continue;
}
if (char === "[") {
bracketDepth++;
current += char;
continue;
}
if (char === "]") {
bracketDepth = Math.max(0, bracketDepth - 1);
current += char;
continue;
}
if (char === "{") {
braceDepth++;
current += char;
continue;
}
if (char === "}") {
braceDepth = Math.max(0, braceDepth - 1);
current += char;
continue;
}
if (char === "," && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) {
const token = current.trim();
if (token.length > 0) {
tokens.push(token);
}
current = "";
continue;
}
current += char;
}
const tail = current.trim();
if (tail.length > 0) {
tokens.push(tail);
}
return tokens;
}
function normalizeScopeField(value: unknown): string[] | undefined {
const normalized = normalizeRuleField(value);
if (!normalized) {
return undefined;
}
const tokens = normalized.flatMap(splitScopeTokens).filter(item => item.length > 0);
if (tokens.length === 0) {
return undefined;
}
return Array.from(new Set(tokens));
}
/**
* Heuristic for condition shorthand that looks like a file glob (for example `*.rs`).
*/
function isLikelyFileGlob(value: string): boolean {
const token = value.trim();
if (token.length === 0) {
return false;
}
if (/[\\^$+|()]/.test(token)) {
return false;
}
if (!/[?*[\]{}]/.test(token)) {
return false;
}
if (token.includes("/")) {
return true;
}
return /^\*\.[^\s/]+$/.test(token);
}
/**
* Parse `condition` + `scope` from rule frontmatter.
*
* - `condition` accepts string or string[]
* - `scope` accepts string or string[]
* - legacy `ttsr_trigger` / `ttsrTrigger` are accepted as a `condition` fallback
* - condition tokens that look like file globs become scope shorthands:
* `*.rs` => `tool:edit(*.rs)`, `tool:write(*.rs)` and a catch-all condition `.*`
*/
export function parseRuleConditionAndScope(frontmatter: RuleFrontmatter): Pick<Rule, "condition" | "scope"> {
const rawCondition = frontmatter.condition ?? frontmatter.ttsr_trigger ?? frontmatter.ttsrTrigger;
const parsedCondition = normalizeRuleField(rawCondition);
const parsedScope = normalizeScopeField(frontmatter.scope);
const inferredScope: string[] = [];
const condition: string[] = [];
for (const token of parsedCondition ?? []) {
if (isLikelyFileGlob(token)) {
for (const toolName of CONDITION_GLOB_SCOPE_TOOLS) {
inferredScope.push(`tool:${toolName}(${token})`);
}
continue;
}
condition.push(token);
}
if (condition.length === 0 && inferredScope.length > 0) {
condition.push(".*");
}
const scope = [...(parsedScope ?? []), ...inferredScope];
return {
condition: condition.length > 0 ? Array.from(new Set(condition)) : undefined,
scope: scope.length > 0 ? Array.from(new Set(scope)) : undefined,
};
}
export const ruleCapability = defineCapability<Rule>({
id: "rules",
displayName: "Rules",
description: "Project-specific rules and constraints (Cursor MDC, Windsurf, Cline formats)",
key: rule => rule.name,
validate: rule => {
if (!rule.name) return "Missing rule name";
if (!rule.path) return "Missing rule path";
if (!rule.content || typeof rule.content !== "string") return "Rule must have content";
return undefined;
},
});