Compare commits

...

4 Commits

Author SHA1 Message Date
Eve Killaby b7bdfcf6fa feat(rules): Add tool-call path harvesting for path-scoped Cline Rules. 2026-01-14 15:09:53 -08:00
Eve Killaby 86de6ae16b feat(rules): Initial implementation based on plan doc. 2026-01-14 15:09:53 -08:00
Eve Killaby 3b5f2cb1e4 update frontmatter plan 2026-01-14 15:09:53 -08:00
Eve Killaby 1200670a9b feat(rules): Write technical design / implementation plan doc. 2026-01-14 15:09:53 -08:00
16 changed files with 1497 additions and 53 deletions
@@ -0,0 +1,811 @@
# Cline Rules: YAML `paths` Frontmatter — Implementation Plan
This document proposes a technical design for adding **conditional applicability** to **Cline Rules** using **YAML frontmatter** with a `paths` list (similar in spirit to Claude Code rules).
Scope constraints (explicit):
- ✅ Only plan + design for **Cline Rules** (`~/Documents/Cline/Rules` and workspace `.clinerules{,/}`) and **remote global rules**.
- ✅ Only add **one conditional mechanism**: YAML frontmatter key `paths`.
- ✅ Include matching semantics + prompt-build integration + toggle behavior + tests.
- ❌ Exclude workflows.
- ❌ Exclude hooks.
- ❌ Exclude other conditionals (model/provider/mode/etc.).
---
## 1. Background: Current behavior (baseline)
Today, Cline Rules are concatenated verbatim into the system prompt, with enable/disable determined solely by toggles:
- Global file-based rules: `~/Documents/Cline/Rules/*`
- Workspace rules: `<workspace>/.clinerules` (directory or legacy file)
- Remote rules: `remoteGlobalRules[]` from remote config
The prompt is (re)built for each API request in `Task.attemptApiRequest()` (`src/core/task/index.ts`).
There is **no metadata parsing** for Cline Rules.
---
## 2. Goal
Allow a Cline Rule file to declare YAML frontmatter:
```md
---
paths:
- "apps/web/**"
- "packages/*/src/**"
---
<rule body>
```
And only include the rules body in the prompt if the current workspace context “matches” one of the declared `paths`.
This is intended as the **minimal on-ramp** to conditional rules.
### 2.1 Architecture Principle: Generic Foundation
While v1 implements **only** the `paths` conditional, the implementation will use generic abstractions to enable easy addition of future conditionals (e.g., `mode`, `provider`, `model`, `tags`) without major refactoring.
Key design decisions:
1. **Rule Evaluation Context** - Instead of passing `pathContext: string[]` throughout the codebase, we'll use a structured `RuleEvaluationContext` object that can grow over time.
2. **Conditional Evaluator Pattern** - Each conditional type has its own evaluator function with a consistent signature, making it easy to add new conditionals by registering new evaluators.
3. **Generic Naming** - Use "evaluation" and "conditional" terminology rather than path-specific names in core abstractions.
4. **V1 Constraint** - Despite the generic foundation, v1 will only implement and document `paths`. Other conditionals will be added in future iterations.
---
## 3. Proposed rule schema (v1)
### 3.1 Frontmatter structure
The frontmatter uses YAML format and supports conditional fields. In v1, only `paths` is implemented.
```yaml
---
paths:
- "apps/web/**"
- "packages/*/src/**"
---
```
**V1 supported conditionals:**
- `paths?: string[]` - Rule applies only when context matches these path patterns. If omitted or empty → rule applies universally (current behavior).
**Future conditionals (not implemented in v1):**
- `mode?: "act" | "plan" | ["act", "plan"]` - Rule applies in specific modes
- `provider?: string | string[]` - Rule applies for specific providers
- `model?: string | string[]` - Rule applies for specific models
- `tags?: string | string[]` - Rule applies when workspace has matching tags
### 3.2 Conditional evaluation semantics
When multiple conditionals are present (future versions):
- All conditionals must evaluate to true (AND logic)
- If a conditional key is omitted, it places no constraint (always true for that dimension)
Example (future):
```yaml
---
paths: ["src/**"]
mode: "act"
---
```
This rule applies only when working in `src/**` AND in act mode.
### 3.3 Body
The remainder of the markdown file after frontmatter.
### 3.4 Validation rules
- If YAML frontmatter fails to parse: **fail open** (treat as universal rule) and keep entire file as body.
- If a conditional key is unrecognized: ignore that key (treat as if omitted).
- If a conditional value has wrong type: ignore that conditional.
- Rationale: Robustness over strictness; avoid silently dropping rules.
---
## 4. Conditional evaluation semantics
We need a deterministic and explainable definition of "does this rule apply right now?"
### 4.0 Generic evaluation architecture
The rule evaluation system uses a **conditional evaluator pattern**:
```typescript
// Generic context object passed to all evaluators
type RuleEvaluationContext = {
paths?: string[] // v1: implemented
mode?: "act" | "plan" // future
provider?: string // future
model?: string // future
// extensible for future conditionals
}
// Generic evaluator signature
type ConditionalEvaluator = (
frontmatterValue: unknown,
context: RuleEvaluationContext
) => boolean
// Registry of evaluators
const conditionalEvaluators: Record<string, ConditionalEvaluator> = {
paths: evaluatePathsConditional, // v1: implemented
mode: evaluateModeConditional, // future: placeholder
provider: evaluateProviderConditional, // future: placeholder
// future conditionals register here
}
// Generic rule evaluation
function evaluateRuleConditionals(
frontmatter: Record<string, unknown>,
context: RuleEvaluationContext
): boolean {
// A rule applies if ALL present conditionals evaluate to true
for (const [key, value] of Object.entries(frontmatter)) {
const evaluator = conditionalEvaluators[key]
if (!evaluator) continue // unknown conditional: ignore
if (!evaluator(value, context)) return false
}
return true // all conditionals passed (or none present)
}
```
**V1 Implementation Note:** In v1, only `evaluatePathsConditional` is implemented. The generic architecture exists but only handles the `paths` key. Future conditionals will add new evaluators to the registry without changing the core evaluation logic.
### 4.1 Paths Conditional: Inputs to matching
We should treat `paths` as a proxy for the implicit condition:
> “When the work you are about to do involves files matching these paths, these expectations apply.”
At the exact moment Cline builds the system prompt (immediately before `api.createMessage(...)`), it does **not** have a perfect, structured representation of what the request “is about.”
So we need a proxy set of **evidence** to determine which paths are relevant for *this request*.
#### Recommended v1 evidence stack
The evidence stack is not prioritized for comparison — we are looking for *any* positive indication that a rule should (in spirit) apply. All sources contribute to a single candidate path set.
1) **Explicit referenced paths (high confidence)**
- Paths resolved from user mentions (e.g. `@file` / "context mentions" that resolve to workspace files).
- Paths targeted by tool calls in the immediately preceding turn(s), especially:
- `read_file`, `write_to_file`, `apply_patch`, `list_files`, `search_files`.
2) **Path-like strings in user message text (medium confidence)**
- Parse the current user message for path-like patterns (e.g., `apps/web/`, `src/components/Button.tsx`, `packages/*/lib`).
- This solves the **"first turn" problem**: a user typing "add a new component to apps/web" should activate a rule scoped to `apps/web/**`, even though no file context exists yet.
- Implementation notes:
- Use a regex to extract candidates that look like relative paths (contain `/`, no spaces, reasonable file/dir name characters).
- Validate candidates against actual workspace structure when possible (exists check) to reduce false positives.
- If validation is too expensive, treat these as low-confidence candidates that still contribute to matching.
3) **Observed workspace context (medium confidence)**
- **Visible files (open editors)**: Files currently visible in VS Code editor panes.
- **Open tabs**: All files with open tabs in VS Code (may not be visible but are in the tab bar).
- **Recently modified files** (scoped definition — see §4.1.1 below).
4) **Fallback (conservative)**
- If there is no evidence from any source, do **not** activate path-scoped rules.
#### 4.1.1 Defining "recently modified files"
To avoid surprising activations, "recently modified" is **strictly scoped** for v1:
- **Scope**: Files modified **by Cline** (via `write_to_file`, `apply_patch`, or similar tools) **during the current task**.
- **Not included**: Files modified by the user directly, files modified in previous tasks, or files from git history.
- **Rationale**: This keeps the evidence set predictable and directly tied to the current interaction. A rule activating because of a file Cline touched makes sense; a rule activating because of a random git change from yesterday does not.
- **Implementation**: Track tool target paths in `Task` state as tools execute. This list is already partially maintained for checkpoint purposes.
#### 4.1.2 Defining "currently applicable files" (summary)
The **candidate path set** for rule matching is the union of:
| Source | Description | Persistence |
|--------|-------------|-------------|
| `@file` mentions | Resolved paths from explicit user mentions | Current turn |
| Tool targets | Paths from tool calls (`read_file`, `write_to_file`, etc.) | Current task (last N turns, e.g., 3) |
| Path-like text | Parsed from user message prose | Current turn |
| Open tabs | All tabbed files in VS Code | Snapshot at prompt build |
| Visible editors | Currently visible editor panes | Snapshot at prompt build |
| Task-modified files | Files Cline has written during this task | Current task |
All paths are normalized to root-relative POSIX format, deduplicated, and capped (e.g., 100 entries max).
Reasoning:
- This aligns with the “rules are constraints for the work youre about to do” mental model.
- It is still deterministic and explainable (“it activated because you referenced/edited X”).
- It avoids heavy repo scans or semantic inference.
### 4.2 Paths Conditional: What paths are matched against
- Use repo-relative paths (relative to `Task.cwd` / primary workspace root).
- Normalize to POSIX-style slashes for glob consistency.
### 4.3 Paths Conditional: Glob implementation
Use a well-tested glob matcher (recommended):
- `minimatch` OR `picomatch`.
Design notes:
- `.clineignore` already uses gitignore semantics via `ignore` library, but these are *not* the same as globbing. Reusing `ignore` would be confusing.
- `picomatch` is fast and handles common glob syntax; `minimatch` is also common. Either is fine; pick one consistent with existing deps.
### 4.4 Paths Conditional: Match rule
A rule with `paths` applies if:
- Any candidate “context path” matches any pattern in `paths`.
Edge cases:
- If no candidate paths exist (no tabs/visible/recently modified):
- Option A (recommended): **do not activate path-scoped rules** (conservative).
- Option B: activate path-scoped rules if pattern is `**` or `/`-equivalent.
Recommendation: Option A.
Rationale: Otherwise path-based conditionals become “randomly always on” early in a task before any file context exists.
### 4.5 Paths Conditional: Multi-root workspaces
We should be compatible with both single-root and multi-root workspaces.
In multi-root workspaces, the simplest mental model is:
> “A `paths` pattern is evaluated against the files path **within whatever workspace root it belongs to**.”
So we do **not** need separate matching logic; we just need to make sure we generate candidate paths from *all* workspace roots.
#### Proposed v1 approach (root-agnostic matching)
1) Build a candidate list of **repo-relative paths per root**
- For every evidence path (mentions/tool targets/visible/open/recent), compute:
- `relPath = path.relative(rootPath, absolutePath)` for the root that contains it
- Normalize `relPath` to POSIX.
2) Match frontmatter `paths[]` globs against `relPath`.
3) A rule applies if **any** candidate `relPath` matches.
This yields the intuitive outcome we want:
- A rule with `paths: ["apps/web/**"]` activates when the request context includes any file under `apps/web/` in *any* workspace root.
#### Implementation note
Even with “root-agnostic matching”, we still need to know which root a path belongs to in order to compute `relPath` correctly. But this can be an internal detail of the candidate-generation step; the matcher can remain purely `glob(pattern) vs relPath`.
---
### 4.6 Paths Conditional: Prompt-build timing and context
This is critical context for why the evidence stack exists.
Prompt building happens for each request in `Task.attemptApiRequest()` and occurs *just before* calling the model provider (`api.createMessage(systemPrompt, ...)`).
At that moment we have:
- The **current requests userContent** (already constructed in `recursivelyMakeClineRequests`).
- The **conversation history**.
- The **environment details** we choose to inject.
We do *not* have:
- A reliable, structured “intent” object describing which files will be touched next.
Therefore, path-scoped rules must be driven by observable evidence (mentions/tool targets/UI context) rather than by perfect prediction.
## 5. Data flow design
### 5.1 Parse frontmatter (shared utility)
We already have frontmatter parsing in `src/core/context/instructions/user-instructions/skills.ts`:
- Regex-based extraction
- `js-yaml` parsing
- Fail-open fallback
Plan:
- Extract this into a reusable helper module, e.g.:
- `src/core/context/instructions/user-instructions/frontmatter.ts`
Proposed API:
```ts
export type FrontmatterParseResult = {
data: Record<string, unknown>
body: string
hadFrontmatter: boolean
parseError?: string
}
export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult
```
Then:
- Update `skills.ts` to use this helper (non-functional change).
- Use the same helper for rule parsing.
### 5.2 Extend rule loading to include metadata
Currently, `getRuleFilesTotalContent()` reads files and concatenates as:
```ts
`${relativePath}\n` + file.trim()
```
We will extend this to:
1) Read file
2) Parse frontmatter
3) Decide applicability
4) If applicable, include **body only** (not frontmatter) in the prompt.
### 5.3 Where applicability is decided
There are two viable insertion points:
#### Option 1 (recommended): Decide applicability inside `getRuleFilesTotalContent()`
Pros:
- Centralizes rule file reading + concatenation.
- Keeps `cline-rules.ts` orchestration simple.
Cons:
- Needs additional input: evaluation context.
Implementation with **generic signature**:
```ts
getRuleFilesTotalContent(
ruleFilePaths: string[],
basePath: string,
toggles: ClineRulesToggles,
opts?: {
evaluationContext?: RuleEvaluationContext
}
)
```
The function:
1. Parses frontmatter from each file
2. Calls `evaluateRuleConditionals(frontmatter, opts.evaluationContext)`
3. Includes body only if evaluation returns true
#### Option 2: Decide applicability in `getGlobalClineRules()` and `getLocalClineRules()`
Pros:
- More explicit; no signature change on helper.
Cons:
- Duplicates logic between global and local.
Recommendation: Option 1 with generic `evaluationContext`.
### 5.4 Building evaluation context in prompt-build step
At prompt build time (`Task.attemptApiRequest()`), we should construct a **RuleEvaluationContext** object.
For v1, this includes only `paths`:
```ts
private buildRuleEvaluationContext(): RuleEvaluationContext {
return {
paths: this.getRulePathContext(), // existing helper, returns string[]
// future: mode, provider, model, etc.
}
}
private getRulePathContext(): string[] {
// Existing implementation from §4.1
// Returns bounded, normalized list of root-relative paths
}
```
This generic structure makes future additions trivial:
```ts
// Future example:
private buildRuleEvaluationContext(): RuleEvaluationContext {
return {
paths: this.getRulePathContext(),
mode: this.mode, // future: add mode
provider: this.api.getInfo().name, // future: add provider
model: this.api.getModel().id, // future: add model
}
}
```
**V1 implementation:**
- Only `paths` is gathered and populated
- Other fields remain undefined
- The generic structure exists but is only partially used
#### v1 rules for building the paths context
1) Include **explicit referenced paths** when available
- Best source: mention parsing already resolves files; we should plumb those resolved paths into a structured list.
- Additionally, record tool target paths as part of task state (or extract them from recent tool executions) so they can influence the next prompt build.
2) Include **observed UI/workspace context**
- visible/open/recently modified
3) Normalize and bound
- convert all to root-relative paths (for each path, pick its containing root)
- convert to POSIX
- de-duplicate and sort for determinism
- cap to N entries (e.g. 50)
Then pass `evaluationContext` into rule loader calls so the loader can decide applicability.
---
## 6. Behavior with toggles
Toggles remain the primary user control.
- If a rule is toggled off → it is not included, regardless of `paths`.
- If toggled on:
- If no `paths` → include.
- If `paths` → include only if applicable.
UI will still show the rule as "enabled" because the toggle is enabled, but it may not be active due to path mismatch.
### 6.1 UI notification for conditional rule activation
When conditional rules (any type, not just paths) are included in the current API request, the user should be informed. This provides transparency and helps users understand why certain behaviors or constraints are being applied.
#### v1 approach: Simple in-chat notification
Display a brief, non-intrusive message in the webview-ui task flow when conditional rules are activated. This appears as part of the API request context (similar to how we show environment details or context mentions).
**Proposed UX:**
- When one or more conditional rules activate, show a collapsible/expandable notice in the chat UI.
- Format:
```
📋 Conditional rules applied: [rule-name-1], [rule-name-2]
```
- Clicking/expanding shows which conditions were met (e.g., "matched paths: apps/web/**, src/**" or future: "mode: act, provider: anthropic")
**Implementation notes:**
Return metadata from `getRuleFilesTotalContent()`:
```ts
type RuleLoadResult = {
content: string
activatedConditionalRules: Array<{
name: string
matchedConditions: Record<string, string[]> // e.g., { "paths": ["apps/web/**"] }
}>
}
```
- Pass this metadata back to the Task
- Emit `say("conditional_rules_applied", metadata)` to webview
- The webview renders a generic conditional notification
**V1 specifics:**
- `matchedConditions` will only contain `"paths"` key
- UI message can show "matched paths: X, Y"
- Future conditionals just add more keys to `matchedConditions`
**Why this matters:**
- Users will otherwise be confused when behavior differs based on what files they're working with.
- Debugging rule issues becomes much easier: "Oh, my `frontend-conventions` rule activated because I mentioned `apps/web/`."
- Builds trust in the conditional system — users can see it working.
### 6.2 Future UI considerations (out of scope for v1)
- "Inactive" indicator in rules list when a rule is toggled on but path-filtered out
- Hover tooltip showing which paths a rule would match
- Quick action to "always include" a path-scoped rule for this task
---
## 7. Remote rules (`remoteGlobalRules`) and `paths`
Remote rules are appended in `getGlobalClineRules()` as `rule.contents`.
We need to support frontmatter in `contents` as well, because enterprises will likely want the same conditional power.
Plan:
- Treat `rule.contents` as a markdown file body that *may* contain YAML frontmatter.
- Parse it with `parseYamlFrontmatter`.
- Apply the same `paths` semantics.
**Important:** `remoteGlobalRules` currently are not prefixed with a file path, only `rule.name`.
We will keep that behavior: `name` functions as the “identifier header” in the combined instruction blob.
---
## 8. Prompt formatting & debuggability
To preserve debuggability, we should ensure the prompt still indicates which rule produced which text.
Current file-based formatting uses:
```
relative/path/to/rule.md
<full file content>
```
After this change, it becomes:
```
relative/path/to/rule.md
<body only (frontmatter removed)>
```
For path-scoped rules, it may be helpful to optionally include a short “(paths matched)” note, but thats extra prompt tokens.
Recommendation for v1: do not add notes.
---
## 9. Testing plan
We need confidence in:
1) YAML parsing correctness
2) Matching semantics
3) No regressions for rules without frontmatter
4) Remote rules handling
### 9.1 Unit tests
Add tests for new helper `parseYamlFrontmatter`:
- no frontmatter → `data={}`, `body=original`
- valid frontmatter with `paths` list
- malformed YAML → fail open
- frontmatter with non-array paths → ignore `paths` (treat as universal)
Add tests for path matching helper:
- exact file match
- glob match (`**`, `*`)
- windows path normalization (ensure posix conversion)
- empty context paths: path-scoped rules inactive
Add tests for `extractPathLikeStrings`:
- extracts paths with `/` separators (e.g., `src/components/Button.tsx`)
- extracts directory paths (e.g., `apps/web/`)
- ignores URLs (e.g., `https://example.com/path`)
- ignores paths with spaces or invalid characters
- handles mixed prose with multiple path candidates
- validates against workspace structure when feasible
- respects deduplication and capping
### 9.2 Integration-ish tests for rule loading
For `getLocalClineRules` / `getGlobalClineRules` (in `cline-rules.ts`):
- create temp workspace with `.clinerules/` and 2 files:
- one universal
- one with paths
- set context paths list and verify only correct rule included
Remote rules:
- simulate `remoteGlobalRules` with a `contents` that has frontmatter
- verify inclusion/exclusion based on context paths + `remoteRulesToggles`
---
## 10. Implementation steps (sequenced)
1) **Add shared frontmatter parser**
- new `frontmatter.ts`
- reuse regex+js-yaml approach from `skills.ts`
2) **Refactor skills** to use shared parser (no behavior changes)
3) **Add generic conditional evaluation system**
- Define `RuleEvaluationContext` type
- Define `ConditionalEvaluator` type
- Create evaluator registry pattern
- Implement `evaluateRuleConditionals()` function
4) **Implement paths conditional evaluator** (v1 only conditional)
- pick glob library (prefer `picomatch` or `minimatch`)
- implement `evaluatePathsConditional(pathsFrontmatter, context)`
- register in `conditionalEvaluators` registry
5) **Add path-like string extraction utility** (paths conditional support)
- implement `extractPathLikeStrings(text: string): string[]`
- regex-based extraction for path-like patterns in prose
- optional: validation against workspace structure
6) **Thread evaluation context into prompt build** (generic infrastructure)
- add `Task.buildRuleEvaluationContext()` (returns generic object)
- add `Task.getRulePathContext()` (paths-specific helper)
- implement evidence gathering for paths from all sources:
- `@file` mentions (from mention parsing)
- tool target paths (from task state)
- path-like strings in user message (new utility)
- open tabs / visible editors (VS Code API)
- task-modified files (from checkpoint/tool tracking)
- normalize, dedupe, and cap at 100 entries
7) **Update rule concatenation with generic evaluation**
- extend `getRuleFilesTotalContent` signature to accept `RuleEvaluationContext`
- use `evaluateRuleConditionals()` to filter rules
- return `RuleLoadResult` with `activatedConditionalRules` metadata
- update `getGlobalClineRules` for remote rule contents similarly
8) **Add UI notification for activated conditional rules**
- add new `say` message type: `"conditional_rules_applied"`
- emit notification from Task when conditional rules activate
- render generic conditional notification in webview-ui chat stream (collapsible)
- show matched conditions (v1: paths only)
9) **Add tests**
- unit tests for frontmatter parsing
- unit tests for generic conditional evaluation
- unit tests for path-like string extraction
- unit tests for paths conditional (globs, edge cases)
- integration tests for rule loading with evaluation context
- remote rules tests
10) **Docs**
- add/update docs.cline.bot content later (out of scope)
- for repo: update any local documentation describing `.clinerules` format
---
## 11. Risks & mitigations
### Risk: nondeterministic “context paths”
If we base matching on visible/open/recent files, rule activation could vary between runs.
Mitigation:
- Use a stable, deterministic set (sorted + deduped).
- Keep the rule inactive if there is zero context.
### Risk: token bloat
If we include too many paths or add verbose debug text.
Mitigation:
- Cap context paths and do not include debug notes in prompt.
### Risk: performance overhead
Parsing YAML for every rule every request could be expensive.
Mitigation:
- Cache parsed frontmatter per file path + mtime (future optimization).
- For v1, keep it simple; rule counts are typically small.
---
## 12. Acceptance criteria
### Core functionality
- A rule file with `paths` frontmatter is only included when it matches the current path context.
- Rules without `paths` behave exactly as before.
- Malformed YAML does not break prompt building (fail open).
- Remote rules can also include `paths` and behave consistently.
### Evidence stack
- Path context is gathered from all defined sources: `@file` mentions, tool targets, path-like strings in user message, open tabs, visible editors, and task-modified files.
- Path-like strings in user message prose are parsed and contribute to matching (solves first-turn problem).
- "Recently modified files" only includes files Cline has written during the current task.
- All paths are normalized to root-relative POSIX format, deduplicated, and capped.
### User visibility
- When path-scoped rules activate, a notification is displayed in the webview-ui chat stream.
- The notification identifies which conditional rules were applied.
### Quality
- All new logic is covered by tests.
- Performance remains acceptable for typical rule counts (< 50 rules).
---
## 13. Future conditional extensions (post-v1)
The generic architecture enables straightforward addition of new conditionals. This section documents the pattern for future work.
### 13.1 Adding a new conditional type
To add a new conditional (e.g., `mode`), follow these steps:
1. **Define the evaluator function:**
```ts
function evaluateModeConditional(
frontmatterValue: unknown,
context: RuleEvaluationContext
): boolean {
// Validate frontmatterValue type
if (typeof frontmatterValue !== "string" && !Array.isArray(frontmatterValue)) {
return true // invalid type: ignore this conditional
}
// Normalize to array
const modes = Array.isArray(frontmatterValue) ? frontmatterValue : [frontmatterValue]
// Check if current mode matches
return context.mode !== undefined && modes.includes(context.mode)
}
```
2. **Register the evaluator:**
```ts
conditionalEvaluators["mode"] = evaluateModeConditional
```
3. **Populate context in `buildRuleEvaluationContext()`:**
```ts
private buildRuleEvaluationContext(): RuleEvaluationContext {
return {
paths: this.getRulePathContext(),
mode: this.mode, // ADD THIS LINE
// ...
}
}
```
4. **Add tests** for the new evaluator (see §9.1 pattern)
5. **Update documentation** to advertise the new conditional
### 13.2 Candidate future conditionals
Potential conditionals that follow this pattern:
| Conditional | Type | Example | Use Case |
|-------------|------|---------|----------|
| `mode` | `"act" \| "plan"` | `mode: "act"` | Different rules for planning vs execution |
| `provider` | `string \| string[]` | `provider: ["anthropic", "openai"]` | Provider-specific best practices |
| `model` | `string \| string[]` | `model: "claude-3-5-sonnet-*"` | Model-specific constraints |
| `tags` | `string \| string[]` | `tags: ["frontend", "typescript"]` | Workspace/project classification |
| `os` | `string \| string[]` | `os: ["darwin", "linux"]` | Platform-specific rules |
| `env` | `string` | `env: "production"` | Environment-specific rules |
Each requires:
- An evaluator function following the `ConditionalEvaluator` signature
- Context population in `buildRuleEvaluationContext()`
- Tests
The core evaluation system (`evaluateRuleConditionals`) requires **no changes**.
### 13.3 Advanced: Conditional expression language (future consideration)
For complex logic beyond AND (e.g., OR, NOT), we could add an expression language:
```yaml
---
when: "(paths:src/** OR paths:lib/**) AND mode:act"
---
```
This is out of scope for v1 and near-term iterations, but the generic architecture doesn't preclude it.
@@ -0,0 +1,30 @@
import { expect } from "chai"
import { parseYamlFrontmatter } from "../frontmatter"
describe("parseYamlFrontmatter", () => {
it("returns original content when no frontmatter", () => {
const input = "Just text"
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(false)
expect(result.data).to.deep.equal({})
expect(result.body).to.equal(input)
})
it("parses valid YAML frontmatter", () => {
const input = `---\npaths:\n - "src/**"\n---\n\nHello`
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(true)
expect(result.parseError).to.equal(undefined)
expect(result.data).to.deep.equal({ paths: ["src/**"] })
expect(result.body.trim()).to.equal("Hello")
})
it("fails open on malformed YAML", () => {
const input = `---\npaths: [invalid\n---\nBody`
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(true)
expect(result.data).to.deep.equal({})
expect(result.body).to.equal(input)
expect(result.parseError).to.be.a("string")
})
})
@@ -0,0 +1,49 @@
import { expect } from "chai"
import { evaluateRuleConditionals, extractPathLikeStrings } from "../rule-conditionals"
describe("rule-conditionals", () => {
describe("evaluateRuleConditionals(paths)", () => {
it("treats missing paths as universal", () => {
const res = evaluateRuleConditionals({}, { paths: [] })
expect(res.passed).to.equal(true)
})
it("does not activate path-scoped rules with empty context", () => {
const res = evaluateRuleConditionals({ paths: ["src/**"] }, { paths: [] })
expect(res.passed).to.equal(false)
})
it("matches when any candidate path matches any glob", () => {
const res = evaluateRuleConditionals({ paths: ["src/**", "apps/**"] }, { paths: ["src/index.ts"] })
expect(res.passed).to.equal(true)
expect(res.matchedConditions.paths).to.deep.equal(["src/**"])
})
it("ignores invalid paths type (fail-open)", () => {
const res = evaluateRuleConditionals({ paths: "src/**" as any }, { paths: [] })
expect(res.passed).to.equal(true)
})
})
describe("extractPathLikeStrings", () => {
it("extracts basic relative paths", () => {
const res = extractPathLikeStrings("edit apps/web/src/App.tsx and packages/foo/src")
expect(res).to.deep.equal(["apps/web/src/App.tsx", "packages/foo/src"])
})
it("extracts simple filenames with extensions (no slashes)", () => {
const res = extractPathLikeStrings("Does foo.md exist? If not, create foo.md")
expect(res).to.deep.equal(["foo.md"])
})
it("does not extract bare words without an extension", () => {
const res = extractPathLikeStrings("Please create foo and then update bar")
expect(res).to.deep.equal([])
})
it("ignores URLs", () => {
const res = extractPathLikeStrings("see https://example.com/a/b and edit src/index.ts")
expect(res).to.deep.equal(["src/index.ts"])
})
})
})
@@ -0,0 +1,39 @@
import { expect } from "chai"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { getRuleFilesTotalContentWithMetadata } from "../rule-helpers"
describe("rule loading with paths frontmatter", () => {
it("filters rules by evaluationContext.paths", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
try {
const rulesDir = path.join(tmp, ".clinerules")
await fs.mkdir(rulesDir, { recursive: true })
await fs.writeFile(path.join(rulesDir, "universal.md"), "Always on")
await fs.writeFile(path.join(rulesDir, "scoped.md"), `---\npaths:\n - "src/**"\n---\n\nOnly for src`)
const files = ["universal.md", "scoped.md"]
const toggles: Record<string, boolean> = {
[path.join(rulesDir, "universal.md")]: true,
[path.join(rulesDir, "scoped.md")]: true,
}
const res1 = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
evaluationContext: { paths: ["src/index.ts"] },
})
expect(res1.content).to.contain("universal.md")
expect(res1.content).to.contain("scoped.md")
expect(res1.content).to.not.contain("paths:")
expect(res1.activatedConditionalRules.map((r) => r.name)).to.include("scoped.md")
const res2 = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
evaluationContext: { paths: ["docs/readme.md"] },
})
expect(res2.content).to.contain("universal.md")
expect(res2.content).to.not.contain("scoped.md")
} finally {
await fs.rm(tmp, { recursive: true, force: true })
}
})
})
@@ -1,4 +1,8 @@
import { getRuleFilesTotalContent, synchronizeRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
import {
getRemoteRulesTotalContentWithMetadata,
getRuleFilesTotalContentWithMetadata,
synchronizeRuleToggles,
} from "@core/context/instructions/user-instructions/rule-helpers"
import { formatResponse } from "@core/prompts/responses"
import { ensureRulesDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import { StateManager } from "@core/storage/StateManager"
@@ -7,18 +11,35 @@ import { fileExistsAtPath, isDirectory, readDirectory } from "@utils/fs"
import fs from "fs/promises"
import path from "path"
import { Controller } from "@/core/controller"
import type { RuleEvaluationContext } from "./rule-conditionals"
export const getGlobalClineRules = async (globalClineRulesFilePath: string, toggles: ClineRulesToggles) => {
export const getGlobalClineRules = async (
globalClineRulesFilePath: string,
toggles: ClineRulesToggles,
opts?: { evaluationContext?: RuleEvaluationContext },
): Promise<{
instructions?: string
activatedConditionalRules: Array<{ name: string; matchedConditions: Record<string, string[]> }>
}> => {
let combinedContent = ""
const activatedConditionalRules: Array<{ name: string; matchedConditions: Record<string, string[]> }> = []
// 1. Get file-based rules
if (await fileExistsAtPath(globalClineRulesFilePath)) {
if (await isDirectory(globalClineRulesFilePath)) {
try {
const rulesFilePaths = await readDirectory(globalClineRulesFilePath)
const rulesFilesTotalContent = await getRuleFilesTotalContent(rulesFilePaths, globalClineRulesFilePath, toggles)
if (rulesFilesTotalContent) {
combinedContent = rulesFilesTotalContent
const rulesFilesTotal = await getRuleFilesTotalContentWithMetadata(
rulesFilePaths,
globalClineRulesFilePath,
toggles,
{
evaluationContext: opts?.evaluationContext,
},
)
if (rulesFilesTotal.content) {
combinedContent = rulesFilesTotal.content
activatedConditionalRules.push(...rulesFilesTotal.activatedConditionalRules)
}
} catch {
console.error(`Failed to read .clinerules directory at ${globalClineRulesFilePath}`)
@@ -33,31 +54,38 @@ export const getGlobalClineRules = async (globalClineRulesFilePath: string, togg
const remoteConfigSettings = stateManager.getRemoteConfigSettings()
const remoteRules = remoteConfigSettings.remoteGlobalRules || []
const remoteToggles = stateManager.getGlobalStateKey("remoteRulesToggles") || {}
for (const rule of remoteRules) {
// If alwaysEnabled, always include; otherwise check toggle
const isEnabled = rule.alwaysEnabled || remoteToggles[rule.name] !== false
if (isEnabled) {
if (combinedContent) {
combinedContent += "\n\n"
}
combinedContent += `${rule.name}\n${rule.contents}`
}
const remoteResult = getRemoteRulesTotalContentWithMetadata(remoteRules, remoteToggles, {
evaluationContext: opts?.evaluationContext,
})
if (remoteResult.content) {
if (combinedContent) combinedContent += "\n\n"
combinedContent += remoteResult.content
activatedConditionalRules.push(...remoteResult.activatedConditionalRules)
}
// 3. Return formatted instructions
if (combinedContent) {
return formatResponse.clineRulesGlobalDirectoryInstructions(globalClineRulesFilePath, combinedContent)
if (!combinedContent) {
return { instructions: undefined, activatedConditionalRules: [] }
}
return undefined
return {
instructions: formatResponse.clineRulesGlobalDirectoryInstructions(globalClineRulesFilePath, combinedContent),
activatedConditionalRules,
}
}
export const getLocalClineRules = async (cwd: string, toggles: ClineRulesToggles) => {
export const getLocalClineRules = async (
cwd: string,
toggles: ClineRulesToggles,
opts?: { evaluationContext?: RuleEvaluationContext },
): Promise<{
instructions?: string
activatedConditionalRules: Array<{ name: string; matchedConditions: Record<string, string[]> }>
}> => {
const clineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
let clineRulesFileInstructions: string | undefined
let instructions: string | undefined
const activatedConditionalRules: Array<{ name: string; matchedConditions: Record<string, string[]> }> = []
if (await fileExistsAtPath(clineRulesFilePath)) {
if (await isDirectory(clineRulesFilePath)) {
@@ -68,9 +96,12 @@ export const getLocalClineRules = async (cwd: string, toggles: ClineRulesToggles
[".clinerules", "skills"],
])
const rulesFilesTotalContent = await getRuleFilesTotalContent(rulesFilePaths, cwd, toggles)
if (rulesFilesTotalContent) {
clineRulesFileInstructions = formatResponse.clineRulesLocalDirectoryInstructions(cwd, rulesFilesTotalContent)
const rulesFilesTotal = await getRuleFilesTotalContentWithMetadata(rulesFilePaths, cwd, toggles, {
evaluationContext: opts?.evaluationContext,
})
if (rulesFilesTotal.content) {
instructions = formatResponse.clineRulesLocalDirectoryInstructions(cwd, rulesFilesTotal.content)
activatedConditionalRules.push(...rulesFilesTotal.activatedConditionalRules)
}
} catch {
console.error(`Failed to read .clinerules directory at ${clineRulesFilePath}`)
@@ -78,9 +109,25 @@ export const getLocalClineRules = async (cwd: string, toggles: ClineRulesToggles
} else {
try {
if (clineRulesFilePath in toggles && toggles[clineRulesFilePath] !== false) {
const ruleFileContent = (await fs.readFile(clineRulesFilePath, "utf8")).trim()
if (ruleFileContent) {
clineRulesFileInstructions = formatResponse.clineRulesLocalFileInstructions(cwd, ruleFileContent)
const raw = (await fs.readFile(clineRulesFilePath, "utf8")).trim()
if (raw) {
const { parseYamlFrontmatter } = await import("./frontmatter")
const { evaluateRuleConditionals } = await import("./rule-conditionals")
const parsed = parseYamlFrontmatter(raw)
if (parsed.hadFrontmatter && parsed.parseError) {
instructions = formatResponse.clineRulesLocalFileInstructions(cwd, raw)
} else {
const { passed, matchedConditions } = evaluateRuleConditionals(
parsed.data,
opts?.evaluationContext ?? {},
)
if (passed) {
instructions = formatResponse.clineRulesLocalFileInstructions(cwd, parsed.body.trim())
if (parsed.hadFrontmatter && Object.keys(matchedConditions).length > 0) {
activatedConditionalRules.push({ name: GlobalFileNames.clineRules, matchedConditions })
}
}
}
}
}
} catch {
@@ -89,7 +136,7 @@ export const getLocalClineRules = async (cwd: string, toggles: ClineRulesToggles
}
}
return clineRulesFileInstructions
return { instructions, activatedConditionalRules }
}
export async function refreshClineRulesToggles(
@@ -0,0 +1,34 @@
import * as yaml from "js-yaml"
export type FrontmatterParseResult = {
data: Record<string, unknown>
body: string
hadFrontmatter: boolean
/** Present only when YAML frontmatter was detected but failed to parse. */
parseError?: string
}
/**
* Parse YAML frontmatter from markdown content.
*
* Behavior is intentionally fail-open:
* - If YAML fails to parse, returns data={} and body=original markdown.
* - If no frontmatter exists, returns data={} and body=original markdown.
*/
export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
const match = markdown.match(frontmatterRegex)
if (!match) {
return { data: {}, body: markdown, hadFrontmatter: false }
}
const [, yamlContent, body] = match
try {
const data = (yaml.load(yamlContent) as Record<string, unknown>) || {}
return { data, body, hadFrontmatter: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { data: {}, body: markdown, hadFrontmatter: true, parseError: message }
}
}
@@ -0,0 +1,146 @@
import * as path from "path"
import picomatch from "picomatch"
export type RuleEvaluationContext = {
/**
* Candidate workspace-relative paths that represent the current request context.
* These should be POSIX-style paths, relative to their workspace root.
*/
paths?: string[]
}
export type ConditionalEvaluator = (frontmatterValue: unknown, context: RuleEvaluationContext) => boolean
type MatchedConditions = Record<string, string[]>
type ConditionalEvaluatorResult = {
passed: boolean
matched?: string[]
}
type ConditionalEvaluatorWithMatch = (frontmatterValue: unknown, context: RuleEvaluationContext) => ConditionalEvaluatorResult
function toPosix(p: string): string {
return p.replace(/\\/g, "/")
}
function isNonEmptyStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === "string" && v.length > 0)
}
const evaluatePathsConditional: ConditionalEvaluatorWithMatch = (frontmatterValue: unknown, context: RuleEvaluationContext) => {
// Invalid type -> ignore conditional (fail-open)
if (!isNonEmptyStringArray(frontmatterValue)) {
return { passed: true }
}
const patterns = frontmatterValue.map((p) => p.trim()).filter(Boolean)
// Empty list -> universal
if (patterns.length === 0) {
return { passed: true }
}
const candidatePaths = (context.paths || []).map((p) => toPosix(p)).filter(Boolean)
// Conservative: no evidence => do not activate path-scoped rules
if (candidatePaths.length === 0) {
return { passed: false }
}
const matchedPatterns: string[] = []
for (const pattern of patterns) {
const matcher = picomatch(pattern, { dot: true })
if (candidatePaths.some((candidate) => matcher(candidate))) {
matchedPatterns.push(pattern)
}
}
return { passed: matchedPatterns.length > 0, matched: matchedPatterns.length > 0 ? matchedPatterns : undefined }
}
const conditionalEvaluators: Record<string, ConditionalEvaluatorWithMatch> = {
paths: evaluatePathsConditional,
}
export function evaluateRuleConditionals(
frontmatter: Record<string, unknown>,
context: RuleEvaluationContext,
): {
passed: boolean
matchedConditions: MatchedConditions
} {
const matchedConditions: MatchedConditions = {}
for (const [key, value] of Object.entries(frontmatter)) {
const evaluator = conditionalEvaluators[key]
if (!evaluator) {
continue // unknown conditional: ignore
}
const result = evaluator(value, context)
if (!result.passed) {
return { passed: false, matchedConditions: {} }
}
if (result.matched && result.matched.length > 0) {
matchedConditions[key] = result.matched
}
}
return { passed: true, matchedConditions }
}
/**
* Extracts path-like strings from user text to help enable first-turn activation.
* This is intentionally heuristic and conservative.
*/
export function extractPathLikeStrings(text: string): string[] {
if (!text) return []
// 1) Remove URLs to avoid false positives.
const withoutUrls = text.replace(/\b\w+:\/\/[^\s]+/g, " ")
// 2) Match tokens that look like paths.
// - Either contain at least one slash (e.g. src/index.ts)
// - Or look like a simple filename with an extension (e.g. foo.md)
// (no slashes; conservative to reduce false positives).
const tokenRegex =
/(?:^|[\s([{"'`])((?:[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\/?|[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,10}))(?=$|[\s)\]}"'`,.;:!?])/g
const matches: string[] = []
let match: RegExpExecArray | null
while ((match = tokenRegex.exec(withoutUrls))) {
const candidate = match[1]
if (!candidate) continue
// Normalize away leading ./
const normalized = candidate.startsWith("./") ? candidate.slice(2) : candidate
// Avoid absurdly long tokens
if (normalized.length > 300) continue
matches.push(normalized)
}
// De-dupe while preserving order
const seen = new Set<string>()
const result: string[] = []
for (const m of matches) {
const posix = m.replace(/\\/g, "/")
if (posix === "/" || posix.startsWith("/") || posix.includes("..")) {
// We only want repo/workspace-relative hints here.
continue
}
if (!seen.has(posix)) {
seen.add(posix)
result.push(posix)
}
}
return result
}
/**
* Normalize an absolute filesystem path to a workspace-root-relative POSIX path.
* Returns undefined if the absolute path is not within the given root.
*/
export function toWorkspaceRelativePosixPath(absPath: string, workspaceRoot: string): string | undefined {
const rel = path.relative(workspaceRoot, absPath)
// Outside the root
if (rel.startsWith("..") || path.isAbsolute(rel)) return undefined
return toPosix(rel)
}
@@ -5,6 +5,8 @@ import { fileExistsAtPath, isDirectory, readDirectory } from "@utils/fs"
import fs from "fs/promises"
import * as path from "path"
import { Controller } from "@/core/controller"
import { parseYamlFrontmatter } from "./frontmatter"
import { evaluateRuleConditionals, RuleEvaluationContext } from "./rule-conditionals"
/**
* Recursively traverses directory and finds all files, including checking for optional whitelisted file extension
@@ -143,7 +145,29 @@ export function combineRuleToggles(toggles1: ClineRulesToggles, toggles2: ClineR
* Read the content of rules files
*/
export const getRuleFilesTotalContent = async (rulesFilePaths: string[], basePath: string, toggles: ClineRulesToggles) => {
const ruleFilesTotalContent = await Promise.all(
return (await getRuleFilesTotalContentWithMetadata(rulesFilePaths, basePath, toggles)).content
}
export type ActivatedConditionalRule = {
name: string
matchedConditions: Record<string, string[]>
}
export type RuleLoadResult = {
content: string
activatedConditionalRules: ActivatedConditionalRule[]
}
export const getRuleFilesTotalContentWithMetadata = async (
rulesFilePaths: string[],
basePath: string,
toggles: ClineRulesToggles,
opts?: { evaluationContext?: RuleEvaluationContext },
): Promise<RuleLoadResult> => {
const activatedConditionalRules: ActivatedConditionalRule[] = []
const evaluationContext = opts?.evaluationContext ?? {}
const parts = await Promise.all(
rulesFilePaths.map(async (filePath) => {
const ruleFilePath = path.resolve(basePath, filePath)
const ruleFilePathRelative = path.relative(basePath, ruleFilePath)
@@ -152,10 +176,72 @@ export const getRuleFilesTotalContent = async (rulesFilePaths: string[], basePat
return null
}
return `${ruleFilePathRelative}\n` + (await fs.readFile(ruleFilePath, "utf8")).trim()
const raw = (await fs.readFile(ruleFilePath, "utf8")).trim()
if (!raw) {
return null
}
const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(raw)
// Fail open: YAML parse errors treat entire file as body and universal applicability.
if (hadFrontmatter && parseError) {
return `${ruleFilePathRelative}\n${raw}`
}
const { passed, matchedConditions } = evaluateRuleConditionals(data, evaluationContext)
if (!passed) {
return null
}
if (hadFrontmatter && Object.keys(matchedConditions).length > 0) {
activatedConditionalRules.push({ name: ruleFilePathRelative, matchedConditions })
}
return `${ruleFilePathRelative}\n${body.trim()}`
}),
).then((contents) => contents.filter(Boolean).join("\n\n"))
return ruleFilesTotalContent
)
return {
content: parts.filter(Boolean).join("\n\n"),
activatedConditionalRules,
}
}
export function getRemoteRulesTotalContentWithMetadata(
remoteRules: GlobalInstructionsFile[],
remoteToggles: ClineRulesToggles,
opts?: { evaluationContext?: RuleEvaluationContext },
): RuleLoadResult {
const activatedConditionalRules: ActivatedConditionalRule[] = []
const evaluationContext = opts?.evaluationContext ?? {}
let combinedContent = ""
for (const rule of remoteRules) {
const isEnabled = rule.alwaysEnabled || remoteToggles[rule.name] !== false
if (!isEnabled) continue
const raw = (rule.contents || "").trim()
if (!raw) continue
const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(raw)
if (hadFrontmatter && parseError) {
// Fail open: include entire raw contents
if (combinedContent) combinedContent += "\n\n"
combinedContent += `${rule.name}\n${raw}`
continue
}
const { passed, matchedConditions } = evaluateRuleConditionals(data, evaluationContext)
if (!passed) continue
if (hadFrontmatter && Object.keys(matchedConditions).length > 0) {
activatedConditionalRules.push({ name: rule.name, matchedConditions })
}
if (combinedContent) combinedContent += "\n\n"
combinedContent += `${rule.name}\n${body.trim()}`
}
return { content: combinedContent, activatedConditionalRules }
}
/**
@@ -2,28 +2,16 @@ import { ensureSkillsDirectoryExists, GlobalFileNames } from "@core/storage/disk
import type { SkillContent, SkillMetadata } from "@shared/skills"
import { fileExistsAtPath, isDirectory } from "@utils/fs"
import * as fs from "fs/promises"
import * as yaml from "js-yaml"
import * as path from "path"
import { parseYamlFrontmatter } from "./frontmatter"
/**
* Parse YAML frontmatter from markdown content.
*/
/** Parse YAML frontmatter from markdown content (shared helper). */
function parseFrontmatter(fileContent: string): { data: Record<string, unknown>; content: string } {
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
const match = fileContent.match(frontmatterRegex)
if (!match) {
return { data: {}, content: fileContent }
}
const [, yamlContent, body] = match
try {
const data = yaml.load(yamlContent) as Record<string, unknown>
return { data: data || {}, content: body }
} catch (error) {
console.warn("Failed to parse YAML frontmatter:", error)
return { data: {}, content: fileContent }
const result = parseYamlFrontmatter(fileContent)
if (result.parseError) {
console.warn("Failed to parse YAML frontmatter:", result.parseError)
}
return { data: result.data, content: result.body }
}
/**
+7
View File
@@ -17,6 +17,13 @@ export class TaskState {
// Map of tool names to their tool_use_id for creating proper ToolResultBlockParam
toolUseIdMap: Map<string, string> = new Map()
/**
* Workspace-relative (POSIX) paths inferred from the assistant's tool calls.
* This represents the model's "intent" to operate on a file path, and is used
* as additional evidence for activating path-scoped Cline Rules on subsequent turns.
*/
rulePathIntentCandidates: Set<string> = new Set()
// Presentation locks
presentAssistantMessageLocked = false
presentAssistantMessageHasPendingUpdates = false
+67
View File
@@ -303,6 +303,70 @@ export class ToolExecutor {
}
}
/**
* Harvest workspace-relative path intent from tool calls.
*
* This is used to help activate path-scoped Cline Rules on subsequent turns,
* even when the user did not explicitly mention the file path (or the file
* does not exist yet).
*/
private harvestRulePathIntent(block: ToolUse): void {
try {
// Only harvest from file-modifying tools to minimize false positives.
if (block.partial) return
const addCandidate = (candidate: string | undefined) => {
if (!candidate) return
// If it's an absolute path, attempt to map to workspace-relative using known roots.
const isAbs = candidate.startsWith("/") || /^[A-Za-z]:\\/.test(candidate)
if (isAbs && this.workspaceManager) {
for (const root of this.workspaceManager.getRoots()) {
if (!root?.path) continue
const absPosix = candidate.replace(/\\/g, "/")
const rootPosix = root.path.replace(/\\/g, "/").replace(/\/$/, "")
if (!absPosix.startsWith(rootPosix + "/")) continue
const relPath: string = absPosix.slice(rootPosix.length + 1)
if (relPath) {
candidate = relPath
break
}
}
}
const posix = candidate.replace(/\\/g, "/").replace(/^\//, "")
if (!posix || posix === "/") return
if (posix.includes("..")) return
this.taskState.rulePathIntentCandidates.add(posix)
}
// write_to_file, replace_in_file, new_rule share the WriteToFile handler and use `path`/`absolutePath`.
if (block.name === "write_to_file" || block.name === "replace_in_file" || block.name === "new_rule") {
addCandidate((block.params as any)?.path)
// In some contexts we may have absolutePath (rare in VS Code, more likely in CLI)
addCandidate((block.params as any)?.absolutePath)
return
}
// apply_patch: parse patch header lines to discover target file paths.
if (block.name === "apply_patch") {
const raw = (block.params as any)?.input
if (typeof raw !== "string" || !raw) return
const patchBody = raw
const fileHeaderRegex = /^\*\*\* (?:Add|Update|Delete) File: (.+?)(?:\n|$)/gm
let m: RegExpExecArray | null
while ((m = fileHeaderRegex.exec(patchBody))) {
const filePath = (m[1] || "").trim()
if (!filePath) continue
addCandidate(filePath)
}
return
}
} catch {
// fail-open: intent harvesting is best-effort
}
}
/**
* Check if parallel tool calling is enabled.
* Parallel tool calling is enabled if:
@@ -594,6 +658,9 @@ export class ToolExecutor {
return
}
// Record tool-intended file paths before execution (best-effort)
this.harvestRulePathIntent(block)
// Execute the actual tool
toolResult = await this.coordinator.execute(config, block)
toolWasExecuted = true
+94 -3
View File
@@ -94,6 +94,11 @@ import {
} from "@/shared/messages"
import { ShowMessageType } from "@/shared/proto/index.host"
import { isClineCliInstalled, isCliSubagentContext } from "@/utils/cli-detector"
import {
extractPathLikeStrings,
RuleEvaluationContext,
toWorkspaceRelativePosixPath,
} from "../context/instructions/user-instructions/rule-conditionals"
import { ensureLocalClineDirExists } from "../context/instructions/user-instructions/rule-helpers"
import { discoverSkills, getAvailableSkills } from "../context/instructions/user-instructions/skills"
import { refreshWorkflowToggles } from "../context/instructions/user-instructions/workflows"
@@ -1729,10 +1734,14 @@ export class Task {
this.cwd,
)
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
const globalClineRulesFileInstructions = await getGlobalClineRules(globalClineRulesFilePath, globalToggles)
const evaluationContext = await this.buildRuleEvaluationContext()
const localClineRulesFileInstructions = await getLocalClineRules(this.cwd, localToggles)
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
const globalRules = await getGlobalClineRules(globalClineRulesFilePath, globalToggles, { evaluationContext })
const globalClineRulesFileInstructions = globalRules.instructions
const localRules = await getLocalClineRules(this.cwd, localToggles, { evaluationContext })
const localClineRulesFileInstructions = localRules.instructions
const [localCursorRulesFileInstructions, localCursorRulesDirInstructions] = await getLocalCursorRules(
this.cwd,
cursorLocalToggles,
@@ -1807,6 +1816,18 @@ export class Task {
terminalExecutionMode: this.terminalExecutionMode,
}
// Notify user if any conditional rules were applied for this request
const activatedConditionalRules = [...globalRules.activatedConditionalRules, ...localRules.activatedConditionalRules]
if (activatedConditionalRules.length > 0) {
await this.say(
"info",
JSON.stringify({
type: "conditional_rules_applied",
rules: activatedConditionalRules,
}),
)
}
const { systemPrompt, tools } = await getSystemPrompt(promptContext)
this.useNativeToolCalls = !!tools?.length
@@ -1991,6 +2012,76 @@ export class Task {
yield* iterator
}
private async buildRuleEvaluationContext(): Promise<RuleEvaluationContext> {
return {
paths: await this.getRulePathContext(),
}
}
private async getRulePathContext(): Promise<string[]> {
const candidates: string[] = []
// (0) Tool-intent evidence: paths the assistant has explicitly targeted via tool calls.
// This is especially important for new files (non-existent at the time of first mention).
if (this.taskState.rulePathIntentCandidates?.size) {
candidates.push(...Array.from(this.taskState.rulePathIntentCandidates))
}
// (1) Mention-based evidence from the current request: parse the current user content
// from the most recent api_req_started request block (it contains the userContent in markdown).
// We can't reliably access raw unprocessed user prompt here without threading it through,
// so we use the latest user message text in clineMessages as a proxy.
const clineMessages = this.messageStateHandler.getClineMessages()
const lastUserMsg = [...clineMessages].reverse().find((m) => m.type === "say" && m.say === "task")
if (lastUserMsg?.text) {
candidates.push(...extractPathLikeStrings(lastUserMsg.text))
}
// (2) Visible + open tabs
const roots = this.workspaceManager?.getRoots().map((r) => r.path) ?? [this.cwd]
const rawVisiblePaths = (await HostProvider.window.getVisibleTabs({})).paths
const rawOpenTabPaths = (await HostProvider.window.getOpenTabs({})).paths
for (const abs of [...rawVisiblePaths, ...rawOpenTabPaths]) {
for (const root of roots) {
const rel = toWorkspaceRelativePosixPath(abs, root)
if (rel) {
candidates.push(rel)
break
}
}
}
// (3) Files edited by Cline during this task: use fileContextTracker metadata heuristics.
// We can approximate this by looking for tool messages indicating edits.
for (const msg of clineMessages) {
if (msg.say !== "tool" || !msg.text) continue
try {
const tool = JSON.parse(msg.text) as { tool?: string; path?: string }
if (
(tool.tool === "editedExistingFile" || tool.tool === "newFileCreated" || tool.tool === "fileDeleted") &&
tool.path
) {
candidates.push(tool.path)
}
} catch {
// ignore
}
}
// Normalize/dedupe/cap
const seen = new Set<string>()
const normalized: string[] = []
for (const c of candidates) {
const posix = c.replace(/\\/g, "/").replace(/^\//, "")
if (!posix || posix === "/") continue
if (seen.has(posix)) continue
seen.add(posix)
normalized.push(posix)
if (normalized.length >= 100) break
}
return normalized.sort()
}
async presentAssistantMessage() {
if (this.taskState.abort) {
throw new Error("Cline instance aborted")
+1
View File
@@ -188,6 +188,7 @@ export type ClineSay =
| "task_progress"
| "hook_status"
| "hook_output_stream"
| "conditional_rules_applied" // legacy/placeholder (not currently emitted directly)
export interface ClineSayTool {
tool:
@@ -105,6 +105,7 @@ function convertClineSayToProtoEnum(say: AppClineSay | undefined): ClineSay | un
error_retry: ClineSay.ERROR_RETRY,
hook_status: ClineSay.HOOK_STATUS,
hook_output_stream: ClineSay.HOOK_OUTPUT_STREAM,
conditional_rules_applied: ClineSay.INFO,
generate_explanation: ClineSay.GENERATE_EXPLANATION,
}
@@ -156,6 +157,7 @@ function convertProtoEnumToClineSay(say: ClineSay): AppClineSay | undefined {
[ClineSay.GENERATE_EXPLANATION]: "generate_explanation",
[ClineSay.HOOK_STATUS]: "hook_status",
[ClineSay.HOOK_OUTPUT_STREAM]: "hook_output_stream",
// No dedicated proto enum for conditional_rules_applied; it is currently sent as INFO payload.
}
return mapping[say]
+15
View File
@@ -0,0 +1,15 @@
declare module "picomatch" {
type PicomatchOptions = {
dot?: boolean
nocase?: boolean
ignore?: string | string[]
posix?: boolean
windows?: boolean
}
type PicomatchMatcher = (input: string) => boolean
function picomatch(pattern: string | string[], options?: PicomatchOptions): PicomatchMatcher
export default picomatch
}
@@ -378,6 +378,27 @@ export const ChatRowContent = memo(
return null
}, [message.ask, message.say, message.text])
const conditionalRulesInfo = useMemo(() => {
if (message.say !== "info" || !message.text) return null
try {
const parsed = JSON.parse(message.text) as unknown
if (
!parsed ||
typeof parsed !== "object" ||
(parsed as any).type !== "conditional_rules_applied" ||
!Array.isArray((parsed as any).rules)
) {
return null
}
return parsed as {
type: "conditional_rules_applied"
rules: Array<{ name: string; matchedConditions: Record<string, string[]> }>
}
} catch {
return null
}
}, [message.say, message.text])
// Helper function to check if file is an image
const isImageFile = (filePath: string): boolean => {
const imageExtensions = [".png", ".jpg", ".jpeg", ".webp"]
@@ -385,6 +406,16 @@ export const ChatRowContent = memo(
return extension ? imageExtensions.includes(`.${extension}`) : false
}
if (conditionalRulesInfo) {
const names = conditionalRulesInfo.rules.map((r: { name: string }) => r.name).join(", ")
return (
<div className={HEADER_CLASSNAMES}>
<span style={{ fontWeight: "bold" }}>Conditional rules applied:</span>
<span className="ph-no-capture break-words whitespace-pre-wrap">{names}</span>
</div>
)
}
if (tool) {
const colorMap = {
red: "var(--vscode-errorForeground)",