mirror of
https://github.com/cline/cline.git
synced 2026-09-04 20:02:30 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7bdfcf6fa | |||
| 86de6ae16b | |||
| 3b5f2cb1e4 | |||
| 1200670a9b |
@@ -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 rule’s 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 you’re 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 file’s 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 request’s 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 that’s 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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
Vendored
+15
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user