feat(coding-agent): added builtin default rules and disable controls

- Added 14 bundled TTSR rules (TypeScript and Rust conventions) embedded into the binary via the lowest-priority `builtin-defaults` provider.
- Extracted rule bucketing into `bucketRules` with support for `disabledRules` and `builtinRules` settings.
- Added `ttsr.builtinRules` and `ttsr.disabledRules` settings to control which rules are active per session.
This commit is contained in:
can1357
2026-05-31 02:01:47 +02:00
parent 06dc3976f0
commit caeaf4e4d2
26 changed files with 987 additions and 16 deletions
+1
View File
@@ -11,6 +11,7 @@
- Added `replace block N:` and `delete block N` operators to the `edit` tool: they resolve the syntactic block beginning on line N via tree-sitter (native `blockRangeAt`) and replace or delete its full line span, so a construct can be rewritten or removed without counting its closing line. Unresolvable blocks (unsupported language, blank/closing-delimiter line, or a parse error) are rejected with guidance to use an explicit `replace N..M:` / `delete N..M` range.
- Added an animated pending border for `bash` and `eval` execution blocks: while a command/cell is running, a single dark segment glides clockwise around the block's outer edge (top → right → bottom → left), replacing the previous static accent border. Motion is eased per edge (decelerating into each corner) and timed against a fixed lap duration mapped onto the live perimeter, so streaming a new output line or resizing the terminal nudges the segment proportionally instead of resetting its position. Driven by the existing spinner cadence and gated on the `display.shimmer` setting (no motion when `disabled`).
- Added a `PI_TINY_DTYPE` environment variable that overrides the ONNX quantization/precision used for local tiny models (session titles and Mnemosyne memory tasks), mirroring `PI_TINY_DEVICE`. Unset keeps each model's shipped dtype (currently `q4`); `PI_TINY_DTYPE=fp16` trades speed for fidelity and `PI_TINY_DTYPE=q8` is also accepted, alongside `auto`, `fp32`, `int8`, `uint8`, `bnb4`, `q4f16`, `q2`, `q2f16`, `q1`, and `q1f16`. An unrecognized value fails loudly at worker startup instead of silently loading a different precision.
- Added a bundled set of default rules shipped with the agent (TypeScript/Rust convention rules registered as TTSR conditions). They load via the new lowest-priority `builtin-defaults` discovery provider, so any user/project/tool rule of the same name overrides the bundled copy. Disable the whole set with `ttsr.builtinRules: false`, or drop individual rules (bundled or your own) by name via `ttsr.disabledRules`.
### Changed
@@ -0,0 +1,64 @@
/**
* Rule bucketing
*
* Single funnel that every discovered rule passes through on its way into a
* session. It applies the user's disable levers, registers TTSR rules with the
* manager, and splits the rest into the always-apply and rulebook buckets.
*
* Bucket precedence (matches docs/rulebook-matching-pipeline.md §5):
* 1. TTSR — non-empty `condition` that `TtsrManager.addRule` accepts
* 2. always — `alwaysApply === true`
* 3. rulebook — has a `description`
*/
import type { TtsrManager } from "../export/ttsr";
import { BUILTIN_DEFAULTS_PROVIDER_ID, type Rule } from "./rule";
export interface RuleBuckets {
rulebookRules: Rule[];
alwaysApplyRules: Rule[];
}
export interface BucketRulesOptions {
/** Rule names to drop entirely (bundled defaults and user rules alike). */
disabledRules?: readonly string[];
/** When false, drop every rule from the bundled `builtin-defaults` provider. */
builtinRules?: boolean;
}
/**
* Filter and bucket rules, registering TTSR rules on `ttsrManager` as a side
* effect. Disabled rules are dropped before any bucket assignment, so a
* disabled rule is neither matched as TTSR nor surfaced via `rule://`.
*/
export function bucketRules(
rules: readonly Rule[],
ttsrManager: TtsrManager,
options: BucketRulesOptions = {},
): RuleBuckets {
const includeBuiltin = options.builtinRules !== false;
const disabled = new Set<string>();
for (const raw of options.disabledRules ?? []) {
const name = raw.trim();
if (name.length > 0) disabled.add(name);
}
const rulebookRules: Rule[] = [];
const alwaysApplyRules: Rule[] = [];
for (const rule of rules) {
if (disabled.has(rule.name)) continue;
if (!includeBuiltin && rule._source?.provider === BUILTIN_DEFAULTS_PROVIDER_ID) continue;
const isTtsrRule = rule.condition && rule.condition.length > 0 ? ttsrManager.addRule(rule) : false;
if (isTtsrRule) continue;
if (rule.alwaysApply === true) {
alwaysApplyRules.push(rule);
continue;
}
if (rule.description) {
rulebookRules.push(rule);
}
}
return { rulebookRules, alwaysApplyRules };
}
@@ -9,6 +9,14 @@ import type { SourceMeta } from "./types";
const CONDITION_GLOB_SCOPE_TOOLS = ["edit", "write"] as const;
/**
* Provider id for the bundled default rules shipped with the agent.
* Lowest priority, so any user/project/tool rule of the same name overrides
* a bundled default. Also used to gate the whole bundled set via
* `ttsr.builtinRules`.
*/
export const BUILTIN_DEFAULTS_PROVIDER_ID = "builtin-defaults";
/**
* Parsed frontmatter from rule files.
*/
@@ -1705,6 +1705,26 @@ export const SETTINGS_SCHEMA = {
},
},
"ttsr.builtinRules": {
type: "boolean",
default: true,
ui: {
tab: "context",
label: "Builtin Rules",
description: "Load the default rules shipped with the agent (override individually with ttsr.disabledRules)",
},
},
"ttsr.disabledRules": {
type: "array",
default: [] as string[],
ui: {
tab: "context",
label: "Disabled Rules",
description: "Rule names to ignore entirely (applies to bundled defaults and your own rules)",
},
},
// ────────────────────────────────────────────────────────────────────────
// Editing
// ────────────────────────────────────────────────────────────────────────
@@ -3299,6 +3319,10 @@ export interface TtsrSettings {
interruptMode: "never" | "prose-only" | "tool-only" | "always";
repeatMode: "once" | "after-gap";
repeatGap: number;
/** Bucketing-only (read by bucketRules, not the TtsrManager). */
builtinRules?: boolean;
/** Bucketing-only (read by bucketRules, not the TtsrManager). */
disabledRules?: string[];
}
export interface ExaSettings {
@@ -0,0 +1,39 @@
/**
* Builtin Defaults Provider
*
* Ships a curated set of default rules (mostly TTSR conventions) embedded into
* the binary. Registered at the lowest priority so any user/project/tool rule
* with the same `name` overrides the bundled copy (first-wins dedup by name).
*
* Users disable bundled rules three ways:
* - flip `ttsr.builtinRules` off (drops the whole set),
* - list a name in `ttsr.disabledRules` (drops one rule),
* - define a same-named rule in any higher-priority source (overrides it).
* The first two are enforced in `bucketRules` (see capability/rule-buckets.ts).
*/
import { registerProvider } from "../capability";
import { BUILTIN_DEFAULTS_PROVIDER_ID, type Rule, ruleCapability } from "../capability/rule";
import type { LoadContext, LoadResult } from "../capability/types";
import { BUILTIN_RULE_SOURCES } from "./builtin-rules";
import { buildRuleFromMarkdown, createSourceMeta } from "./helpers";
const DISPLAY_NAME = "Builtin Defaults";
// Lowest priority: every other rule provider wins a name conflict.
const PRIORITY = 1;
async function loadRules(_ctx: LoadContext): Promise<LoadResult<Rule>> {
const items = BUILTIN_RULE_SOURCES.map(({ name, content }) => {
const virtualPath = `${BUILTIN_DEFAULTS_PROVIDER_ID}:${name}.md`;
const source = createSourceMeta(BUILTIN_DEFAULTS_PROVIDER_ID, virtualPath, "user");
return buildRuleFromMarkdown(name, content, virtualPath, source, { ruleName: name });
});
return { items };
}
registerProvider<Rule>(ruleCapability.id, {
id: BUILTIN_DEFAULTS_PROVIDER_ID,
displayName: DISPLAY_NAME,
description: "Default rules shipped with the agent (disable via ttsr.builtinRules / ttsr.disabledRules)",
priority: PRIORITY,
load: loadRules,
});
@@ -0,0 +1,48 @@
/**
* Bundled default rules shipped with the coding agent.
*
* Each markdown source is embedded via `with { type: "text" }` so it survives
* `bun build --compile` (the compiled binary ships no loose rule files; only
* the embedded text). The native source/tarball installs read the same modules.
*
* Registered by the lowest-priority `builtin-defaults` rule provider so any
* user/project/tool rule with the same name overrides the bundled copy.
*/
import rsBoxLeak from "./rs-box-leak.md" with { type: "text" };
import rsFuturePrelude from "./rs-future-prelude.md" with { type: "text" };
import rsLazylock from "./rs-lazylock.md" with { type: "text" };
import rsMatchErgonomics from "./rs-match-ergonomics.md" with { type: "text" };
import rsParkingLot from "./rs-parking-lot.md" with { type: "text" };
import rsResultType from "./rs-result-type.md" with { type: "text" };
import tsBareCatch from "./ts-bare-catch.md" with { type: "text" };
import tsImportType from "./ts-import-type.md" with { type: "text" };
import tsNoAny from "./ts-no-any.md" with { type: "text" };
import tsNoDynamicImport from "./ts-no-dynamic-import.md" with { type: "text" };
import tsNoReturnType from "./ts-no-return-type.md" with { type: "text" };
import tsNoTinyFunctions from "./ts-no-tiny-functions.md" with { type: "text" };
import tsPromiseWithResolvers from "./ts-promise-with-resolvers.md" with { type: "text" };
import tsSetMap from "./ts-set-map.md" with { type: "text" };
/** A bundled rule's stable name and raw markdown (frontmatter + body). */
export interface BuiltinRuleSource {
name: string;
content: string;
}
/** All bundled default rules, ordered by name. */
export const BUILTIN_RULE_SOURCES: readonly BuiltinRuleSource[] = [
{ name: "rs-box-leak", content: rsBoxLeak },
{ name: "rs-future-prelude", content: rsFuturePrelude },
{ name: "rs-lazylock", content: rsLazylock },
{ name: "rs-match-ergonomics", content: rsMatchErgonomics },
{ name: "rs-parking-lot", content: rsParkingLot },
{ name: "rs-result-type", content: rsResultType },
{ name: "ts-bare-catch", content: tsBareCatch },
{ name: "ts-import-type", content: tsImportType },
{ name: "ts-no-any", content: tsNoAny },
{ name: "ts-no-dynamic-import", content: tsNoDynamicImport },
{ name: "ts-no-return-type", content: tsNoReturnType },
{ name: "ts-no-tiny-functions", content: tsNoTinyFunctions },
{ name: "ts-promise-with-resolvers", content: tsPromiseWithResolvers },
{ name: "ts-set-map", content: tsSetMap },
];
@@ -0,0 +1,48 @@
---
description: Never use Box::leak - it intentionally leaks memory
condition: "Box::leak"
scope: "tool:edit(*.rs), tool:write(*.rs)"
---
Never use `Box::leak` to satisfy a lifetime. It intentionally leaks the allocation for the rest of the process.
## Why
- The allocation is never freed.
- It hides ownership bugs.
- It turns lifetime errors into process lifetime growth.
- It makes tests pass while production memory grows.
## Use instead
| Need | Use |
| --- | --- |
| Shared async/thread data | `Arc<T>` or owned values |
| Global lazy state | `LazyLock<T>` or `OnceLock<T>` |
| Text escaping a scope | `String` / `Arc<str>` |
| `'static` callback | `move` closure with owned captures |
| FFI pointer | Explicit owner that frees on drop |
## Examples
```rust
// Bad — leaking to manufacture 'static.
fn label(id: u64) -> &'static str {
Box::leak(Box::new(format!("item_{id}")))
}
// Good — return owned data.
fn label(id: u64) -> String {
format!("item_{id}")
}
// Bad — leaking before spawn.
let state = Box::leak(Box::new(state));
tokio::spawn(async move { use_state(state) });
// Good — share owned state.
let state = Arc::new(state);
tokio::spawn(async move { use_state(&state) });
```
If `Box::leak` looks necessary, fix ownership instead.
@@ -0,0 +1,23 @@
---
description: Use Future not std::future::Future - it's in the prelude
condition: "std::future::Future"
scope: "tool:edit(*.rs), tool:write(*.rs)"
---
Use `Future` directly instead of `std::future::Future` in type positions.
Rust 2024 includes `Future` in the standard prelude. Older editions can import it once with `use std::future::Future;`. Repeating the fully qualified path makes signatures harder to read without adding safety.
## Examples
```rust
// Bad — fully qualified in every signature.
fn fetch() -> impl std::future::Future<Output = Result<Data>> { ... }
fn poll(fut: Pin<&mut dyn std::future::Future<Output = i32>>) { ... }
// Good — use the prelude or one import.
fn fetch() -> impl Future<Output = Result<Data>> { ... }
fn poll(fut: Pin<&mut dyn Future<Output = i32>>) { ... }
```
Pre-2024 edition? Add `use std::future::Future;` at the top.
@@ -0,0 +1,51 @@
---
description: Prefer std::sync::LazyLock over OnceLock and once_cell
condition:
- "once_cell::"
- "OnceLock::new"
scope: "tool:edit(*.rs), tool:write(*.rs)"
---
Prefer `std::sync::LazyLock` over `OnceLock` and the `once_cell` crate when the initializer is known at declaration time.
`LazyLock` stores the cell and initializer together. There is no separate `init()` function, no repeated `get_or_init`, and no missing initialization path.
## once_cell → std
```rust
// Before
use once_cell::sync::Lazy;
static CONFIG: Lazy<String> = Lazy::new(|| "value".to_string());
// After
use std::sync::LazyLock;
static CONFIG: LazyLock<String> = LazyLock::new(|| "value".to_string());
```
## OnceLock → LazyLock
```rust
// Before — fixed initializer hidden in accessor.
use std::sync::OnceLock;
static SETTINGS: OnceLock<Settings> = OnceLock::new();
fn settings() -> &'static Settings {
SETTINGS.get_or_init(Settings::load)
}
// After — initializer lives with the static.
use std::sync::LazyLock;
static SETTINGS: LazyLock<Settings> = LazyLock::new(Settings::load);
```
## Keep OnceLock when runtime input is required
```rust
use std::sync::OnceLock;
static DATABASE: OnceLock<Database> = OnceLock::new();
fn init_database(url: &str) {
let _ = DATABASE.set(Database::connect(url));
}
```
Do not add `once_cell` for new code. Use the standard library equivalent.
@@ -0,0 +1,67 @@
---
description: Use match ergonomics instead of ref/ref mut patterns
condition:
- "\\(ref mut "
- "\\(ref [a-z_]"
scope: "tool:edit(*.rs), tool:write(*.rs)"
---
Use match ergonomics instead of explicit `ref` / `ref mut` patterns. Borrow the scrutinee and let bindings receive references.
## Shared references
```rust
// Before
match value {
Some(ref item) => println!("{item}"),
None => {}
}
// After
match &value {
Some(item) => println!("{item}"),
None => {}
}
if let Some(item) = &value {
println!("{item}");
}
```
## Mutable references
```rust
// Before
match value {
Some(ref mut item) => *item += 1,
None => {}
}
// After
match &mut value {
Some(item) => *item += 1,
None => {}
}
if let Some(item) = &mut value {
*item += 1;
}
```
## Result
```rust
// Before
match result {
Ok(ref data) => println!("{data}"),
Err(ref err) => eprintln!("{err}"),
}
// After
match &result {
Ok(data) => println!("{data}"),
Err(err) => eprintln!("{err}"),
}
```
Modern Rust rarely needs `ref` in patterns. Borrow the value being matched.
@@ -0,0 +1,44 @@
---
description: Use parking_lot instead of std::sync for Mutex/RwLock
condition:
- "\\.lock\\(\\)\\.unwrap\\(\\)"
- "\\.read\\(\\)\\.unwrap\\(\\)"
- "\\.write\\(\\)\\.unwrap\\(\\)"
scope: "tool:edit(*.rs), tool:write(*.rs)"
---
Use `parking_lot::{Mutex, RwLock}` instead of `std::sync::{Mutex, RwLock}` when code immediately unwraps lock results.
## Why
- `lock()`, `read()`, and `write()` return guards directly.
- No poisoning error path to unwrap.
- Guards are smaller and faster in common contention cases.
- The call site shows locking, not error handling boilerplate.
## Migration
```rust
// Before
use std::sync::Mutex;
let data = Mutex::new(Vec::new());
let guard = data.lock().unwrap();
// After
use parking_lot::Mutex;
let data = Mutex::new(Vec::new());
let guard = data.lock();
```
## Equivalents
| std::sync | parking_lot |
| --- | --- |
| `Mutex<T>` | `Mutex<T>` |
| `RwLock<T>` | `RwLock<T>` |
| `Condvar` | `Condvar` |
| `Once` | `Once` |
## Keep async locks async
Use `tokio::sync::Mutex` / `tokio::sync::RwLock` when a guard is held across `.await` or the lock belongs to async coordination.
@@ -0,0 +1,19 @@
---
description: Result type aliases must include a defaulted error type parameter
condition: "type\\s+Result<[A-Za-z_]\\w*>\\s*="
scope: "tool:edit(*.rs), tool:write(*.rs)"
---
`Result` aliases must expose the error type as a defaulted parameter.
```rust
pub type Result<T, E = anyhow::Error> = std::result::Result<T, E>;
```
Never write:
```rust
type Result<T> = std::result::Result<T, anyhow::Error>;
```
The default keeps common call sites short while preserving escape hatches for precise errors.
@@ -0,0 +1,38 @@
---
description: Use bare `catch {` when the error binding is unused
condition: "catch \\(_"
scope: "tool:edit(*.ts), tool:edit(*.tsx), tool:write(*.ts), tool:write(*.tsx)"
---
Use bare `catch {}` when the caught value is unused. An underscore-prefixed binding adds noise and still allocates a local name.
## Replace
```typescript
// Bad
try {
await loadConfig();
} catch (_err) {
return null;
}
// Good
try {
await loadConfig();
} catch {
return null;
}
```
## Keep a real name when used
```typescript
try {
await saveConfig();
} catch (err) {
logger.error("save failed", { err });
throw err;
}
```
Unused error? Bare `catch`. Used error? Name it for what it carries.
@@ -0,0 +1,42 @@
---
description: "Use `import type`, not `import('pkg').Type` in type positions"
condition: "import\\("
scope: "tool:edit(*.ts), tool:edit(*.tsx), tool:write(*.ts), tool:write(*.tsx)"
---
Use top-level `import type` declarations for type-only dependencies. NEVER write `import("pkg").Type` inside source annotations.
## Why
- Top-level imports expose dependencies immediately.
- Import sorting and deduplication can manage them.
- Signatures stay readable and reviewable.
- Re-exports do not inherit noisy inline paths.
## Avoid
```typescript
// Bad — inline imports hide dependencies in signatures.
function run(client: import("some-sdk").Client, input: import("zod/v4").infer<Schema>): Promise<Output>;
// Bad — annotations become path dumps.
const options: import("some-sdk/config").ClientOptions = { ... };
```
## Use
```typescript
import type { Client } from "some-sdk";
import type { ClientOptions } from "some-sdk/config";
import type { infer as Infer } from "zod/v4";
function run(client: Client, input: Infer<Schema>): Promise<Output>;
const options: ClientOptions = { ... };
```
## Exceptions
- Ambient `.d.ts` globals that must not become modules.
- Generated files whose generator owns import management.
In normal `.ts` / `.tsx` source, use `import type`.
@@ -0,0 +1,56 @@
---
description: "Never use `any` in TypeScript annotations or assertions — use `unknown`, generics, or the actual type"
condition: ": any|as any"
scope: "tool:edit(*.ts), tool:edit(*.tsx), tool:write(*.ts), tool:write(*.tsx)"
---
Never use `: any` or `as any`. They disable type checking exactly where the boundary needs precision.
## Use instead
- `unknown` for unvalidated input.
- A domain type when the shape is known.
- A generic when the caller supplies the shape.
- A type guard when runtime checks establish shape.
- `satisfies` for object literals that must match a contract.
## Parameters and returns
```typescript
// Bad
function readId(value: any): any {
return value.id;
}
// Good — validate unknown input.
function readId(value: unknown): string | undefined {
if (value && typeof value === "object" && "id" in value) {
const candidate = (value as { id: unknown }).id;
return typeof candidate === "string" ? candidate : undefined;
}
}
```
## Assertions
```typescript
// Bad
const root = document.getElementById("root") as any;
root.innerText = "ready";
// Good
const root = document.getElementById("root") as HTMLElement | null;
root?.innerText = "ready";
```
## Object literals
```typescript
// Bad
const config = { port: 3000 } as any as ServerConfig;
// Good
const config = { port: 3000 } satisfies ServerConfig;
```
If a library boundary truly requires an unchecked cast, use `as unknown as T` with a short reason. Never leave a bare `any`.
@@ -0,0 +1,39 @@
---
description: "Do not use `await import()` — use static imports unless dynamic loading is unavoidable"
condition: "await import\\("
scope: "tool:edit(*.ts), tool:edit(*.tsx), tool:write(*.ts), tool:write(*.tsx)"
---
Use static imports for modules known at author time. Reach for `await import()` only when the module specifier is genuinely runtime-selected.
## Why
- Static imports fail during build, not under load.
- Bundlers, type checkers, and tree shakers see them.
- The dependency graph remains reviewable.
- Consumers keep precise module types without casts.
## Avoid
```typescript
// Bad — the module path is a literal.
const { createClient } = await import("some-sdk");
// Bad — dynamic import followed by a shape assertion.
const mod = (await import("./known-module")) as { run?: unknown };
```
## Use
```typescript
import { createClient } from "some-sdk";
import { run } from "./known-module";
```
## Exceptions
- Plugin loading from a runtime registry.
- Platform-specific modules that do not exist everywhere.
- Test cases that intentionally exercise module loading boundaries.
Exception? Add a short comment naming why static import cannot work.
@@ -0,0 +1,45 @@
---
description: "Do not use `ReturnType<typeof fn>` — name the type explicitly"
condition: "ReturnType<"
scope: "tool:edit(*.ts), tool:edit(*.tsx), tool:write(*.ts), tool:write(*.tsx)"
---
Do not publish contracts through `ReturnType<typeof fn>`. Name the type at the module that owns the value and import that name at consumers.
## Why
- Named types document the contract directly.
- Consumers stop coupling to implementation helpers.
- JSDoc and changelog notes attach to the exported type.
- Type errors point at the intended API boundary.
## Avoid
```typescript
// Bad — opaque and coupled to implementation names.
type Config = Awaited<ReturnType<typeof loadConfig>>;
type Message = ReturnType<typeof buildMessage>["message"];
let service: ReturnType<typeof createService> | undefined;
```
## Use
```typescript
// In the module that owns the function:
export interface LoadedConfig {
path: string;
values: Record<string, unknown>;
}
export function loadConfig(path: string): Promise<LoadedConfig> { ... }
// At the consumer:
import type { LoadedConfig } from "./config";
```
## Exceptions
- Timer handles: `ReturnType<typeof setTimeout>` / `setInterval`.
- Generic type utilities where the function is a type parameter.
Concrete function? Export a concrete type.
@@ -0,0 +1,50 @@
---
description: "Do not extract 1-2 line functions that only wrap an expression — inline them"
condition: "\\{\\s*return [^;{}\\n]+;?\\s*\\}|\\b(?:const|let|var)\\s+[\\w$]+\\s*=\\s*(\\([^)]*\\)|[a-zA-Z_$][\\w$]*)\\s*=>\\s*[^{\\n]+$"
scope: "tool:edit(*.ts), tool:edit(*.tsx), tool:write(*.ts), tool:write(*.tsx)"
interruptMode: never
---
Do not extract a function whose whole body is one expression or one `return`. Inline it unless the name creates a durable contract.
## Why
- One-line wrappers hide no real behavior.
- Readers must jump to verify trivial code.
- The signature freezes a shape too early.
- Search and type flow work better with inline expressions.
## Avoid
```typescript
// Bad — pure rename, no behavior added.
function isEmpty(value: string): boolean {
return value.length === 0;
}
const getDisplayName = (user: User) => user.profile.displayName;
function double(value: number) {
return value * 2;
}
if (isEmpty(name)) { ... }
```
## Use
```typescript
if (name.length === 0) { ... }
const displayName = user.profile.displayName;
const doubled = value * 2;
```
## Allowed tiny functions
- Three or more call sites need lockstep behavior.
- Exported name represents a stable domain concept.
- Callback identity matters.
- Type guard preserves narrowing.
- Public API, test seam, or DI boundary needs indirection.
If none apply, inline it.
@@ -0,0 +1,65 @@
---
description: Use Promise.withResolvers() instead of new Promise() constructor
condition: "new Promise\\("
scope: "tool:edit(*.ts), tool:edit(*.tsx), tool:write(*.ts), tool:write(*.tsx)"
---
Use `Promise.withResolvers()` instead of `new Promise((resolve, reject) => ...)`. It keeps control flow linear and exposes typed resolver functions without callback nesting.
## Basic operation
```typescript
// Bad
function delay(ms: number): Promise<void> {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
// Good
function delay(ms: number): Promise<void> {
const { promise, resolve } = Promise.withResolvers<void>();
setTimeout(resolve, ms);
return promise;
}
```
## Event-based completion
```typescript
// Bad
function waitForEvent(emitter: EventEmitter, event: string): Promise<unknown> {
return new Promise((resolve, reject) => {
emitter.once(event, resolve);
emitter.once("error", reject);
});
}
// Good
function waitForEvent(emitter: EventEmitter, event: string): Promise<unknown> {
const { promise, resolve, reject } = Promise.withResolvers<unknown>();
emitter.once(event, resolve);
emitter.once("error", reject);
return promise;
}
```
## Stored resolver
```typescript
class Gate {
#promise: Promise<void>;
#resolve: () => void;
constructor() {
const { promise, resolve } = Promise.withResolvers<void>();
this.#promise = promise;
this.#resolve = resolve;
}
open(): void { this.#resolve(); }
wait(): Promise<void> { return this.#promise; }
}
```
Use the constructor only when an API specifically requires the executor form.
@@ -0,0 +1,28 @@
---
description: Prefer Record<K, V> for small static literals; use Set/Map for anything dynamic
condition: "\\bnew\\s+(Set|Map)\\b"
scope: "tool:edit(**/*.{ts,tsx}), tool:write(**/*.{ts,tsx})"
interruptMode: never
---
Use `Record<K, V>` / `Record<K, true>` for small, static string-keyed lookup tables.
Use `Set` / `Map` when keys are dynamic, non-string, inserted or deleted at runtime, or when code needs `.size`, `.clear()`, stable insertion order, or iterator APIs.
```typescript
// Static literal → Record
const LABEL_BY_KIND: Record<string, string> = {
text: "Text",
json: "JSON",
binary: "Binary",
};
// Dynamic membership → Set
const seen = new Set<string>();
for (const item of items) {
if (seen.has(item.id)) continue;
seen.add(item.id);
}
```
Small fixed table? `Record`. Runtime collection? `Set` / `Map`.
@@ -22,6 +22,7 @@ import "../capability/tool";
// Import providers (each registers itself on import)
import "./agents-md";
import "./builtin";
import "./builtin-defaults";
import "./claude";
import "./claude-plugins";
import "./cline";
+2
View File
@@ -54,6 +54,8 @@ const DEFAULT_SETTINGS: Required<TtsrSettings> = {
interruptMode: "always",
repeatMode: "once",
repeatGap: 10,
builtinRules: true,
disabledRules: [],
};
const DEFAULT_SCOPE: TtsrScope = {
+5 -15
View File
@@ -38,6 +38,7 @@ import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled } from ".
import { createAutoresearchExtension } from "./autoresearch";
import { loadCapability } from "./capability";
import { type Rule, ruleCapability, setActiveRules } from "./capability/rule";
import { bucketRules } from "./capability/rule-buckets";
import { ModelRegistry } from "./config/model-registry";
import {
formatModelString,
@@ -1045,21 +1046,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
options.rules !== undefined
? { items: options.rules, warnings: undefined }
: await loadCapability<Rule>(ruleCapability.id, { cwd });
const rulebookRules: Rule[] = [];
const alwaysApplyRules: Rule[] = [];
for (const rule of rulesResult.items) {
const isTtsrRule = rule.condition && rule.condition.length > 0 ? ttsrManager.addRule(rule) : false;
if (isTtsrRule) {
continue;
}
if (rule.alwaysApply === true) {
alwaysApplyRules.push(rule);
continue;
}
if (rule.description) {
rulebookRules.push(rule);
}
}
const { rulebookRules, alwaysApplyRules } = bucketRules(rulesResult.items, ttsrManager, {
builtinRules: ttsrSettings.builtinRules,
disabledRules: ttsrSettings.disabledRules,
});
if (existingSession.injectedTtsrRules.length > 0) {
ttsrManager.restoreInjected(existingSession.injectedTtsrRules);
}
@@ -0,0 +1,99 @@
import { describe, expect, it } from "bun:test";
import { BUILTIN_DEFAULTS_PROVIDER_ID, type Rule } from "@oh-my-pi/pi-coding-agent/capability/rule";
import { bucketRules } from "@oh-my-pi/pi-coding-agent/capability/rule-buckets";
import { TtsrManager } from "@oh-my-pi/pi-coding-agent/export/ttsr";
function source(provider: string): Rule["_source"] {
return { provider, providerName: provider, path: "/tmp/rule.md", level: "user" };
}
function makeRule(partial: Partial<Rule>): Rule {
return {
name: partial.name ?? "rule",
path: partial.path ?? "/tmp/rule.md",
content: partial.content ?? "body",
globs: partial.globs,
alwaysApply: partial.alwaysApply,
description: partial.description,
condition: partial.condition,
scope: partial.scope,
interruptMode: partial.interruptMode,
_source: partial._source ?? source("native"),
};
}
describe("bucketRules", () => {
it("registers a condition rule as TTSR and excludes it from rulebook/always buckets", () => {
const mgr = new TtsrManager();
const ttsr = makeRule({ name: "no-foo", condition: ["FORBIDDEN"], description: "blocks foo" });
const { rulebookRules, alwaysApplyRules } = bucketRules([ttsr], mgr);
expect(rulebookRules).toHaveLength(0);
expect(alwaysApplyRules).toHaveLength(0);
expect(mgr.checkDelta("contains FORBIDDEN token", { source: "text" }).map(r => r.name)).toEqual(["no-foo"]);
});
it("splits non-TTSR rules into always-apply and rulebook by metadata", () => {
const mgr = new TtsrManager();
const sticky = makeRule({ name: "sticky", alwaysApply: true, description: "sticky desc" });
const book = makeRule({ name: "book", description: "rulebook desc" });
const orphan = makeRule({ name: "orphan" });
const { rulebookRules, alwaysApplyRules } = bucketRules([sticky, book, orphan], mgr);
expect(alwaysApplyRules.map(r => r.name)).toEqual(["sticky"]);
expect(rulebookRules.map(r => r.name)).toEqual(["book"]);
expect(mgr.hasRules()).toBe(false);
});
it("disabledRules drops a rule from every bucket and from TTSR registration", () => {
const mgr = new TtsrManager();
const ttsr = makeRule({ name: "no-foo", condition: ["FORBIDDEN"], description: "blocks foo" });
const book = makeRule({ name: "book", description: "rulebook desc" });
const { rulebookRules } = bucketRules([ttsr, book], mgr, { disabledRules: ["no-foo", "book"] });
expect(rulebookRules).toHaveLength(0);
expect(mgr.hasRules()).toBe(false);
expect(mgr.checkDelta("contains FORBIDDEN token", { source: "text" })).toHaveLength(0);
});
it("disabledRules trims entries and ignores blanks", () => {
const mgr = new TtsrManager();
const ttsr = makeRule({ name: "no-foo", condition: ["FORBIDDEN"] });
bucketRules([ttsr], mgr, { disabledRules: [" no-foo ", "", " "] });
expect(mgr.hasRules()).toBe(false);
});
it("builtinRules:false drops builtin-defaults rules but keeps the rest", () => {
const mgr = new TtsrManager();
const builtin = makeRule({
name: "builtin-foo",
condition: ["FORBIDDEN"],
_source: source(BUILTIN_DEFAULTS_PROVIDER_ID),
});
const userRule = makeRule({ name: "user-foo", condition: ["BANNED"], _source: source("native") });
bucketRules([builtin, userRule], mgr, { builtinRules: false });
expect(mgr.checkDelta("contains FORBIDDEN token", { source: "text" })).toHaveLength(0);
mgr.resetBuffer();
expect(mgr.checkDelta("contains BANNED token", { source: "text" }).map(r => r.name)).toEqual(["user-foo"]);
});
it("includes builtin-defaults rules when builtinRules is unset (default on)", () => {
const mgr = new TtsrManager();
const builtin = makeRule({
name: "builtin-foo",
condition: ["FORBIDDEN"],
_source: source(BUILTIN_DEFAULTS_PROVIDER_ID),
});
bucketRules([builtin], mgr);
expect(mgr.checkDelta("contains FORBIDDEN token", { source: "text" }).map(r => r.name)).toEqual(["builtin-foo"]);
});
});
@@ -0,0 +1,80 @@
/**
* The bundled `builtin-defaults` rule provider ships a curated default rule set
* embedded into the binary. These tests defend that the whole set loads and
* parses, and that the provider sits at the lowest priority so any user/project
* rule of the same name overrides a bundled default (first-wins dedup).
*/
import { describe, expect, it } from "bun:test";
import { getCapability } from "@oh-my-pi/pi-coding-agent/capability";
import { BUILTIN_DEFAULTS_PROVIDER_ID, type Rule, ruleCapability } from "@oh-my-pi/pi-coding-agent/capability/rule";
import type { LoadContext } from "@oh-my-pi/pi-coding-agent/capability/types";
// Register all discovery providers as a side effect.
import "@oh-my-pi/pi-coding-agent/discovery";
const EXPECTED_RULE_NAMES = [
"rs-box-leak",
"rs-future-prelude",
"rs-lazylock",
"rs-match-ergonomics",
"rs-parking-lot",
"rs-result-type",
"ts-bare-catch",
"ts-import-type",
"ts-no-any",
"ts-no-dynamic-import",
"ts-no-return-type",
"ts-no-tiny-functions",
"ts-promise-with-resolvers",
"ts-set-map",
].sort();
function ruleProvider() {
const cap = getCapability(ruleCapability.id);
if (!cap) throw new Error("rules capability missing");
const provider = cap.providers.find(p => p.id === BUILTIN_DEFAULTS_PROVIDER_ID);
if (!provider) throw new Error("builtin-defaults provider missing");
return { cap, provider };
}
async function loadBuiltinRules(): Promise<Rule[]> {
const { provider } = ruleProvider();
const ctx: LoadContext = { cwd: "/tmp", home: "/tmp/home", repoRoot: null };
const result = await (provider.load as (ctx: LoadContext) => Promise<{ items: Rule[] }>)(ctx);
return result.items;
}
describe("builtin-defaults rule provider", () => {
it("loads exactly the bundled default rule set, all attributed to the provider", async () => {
const rules = await loadBuiltinRules();
const names = rules.map(r => r.name).sort();
expect(names).toEqual(EXPECTED_RULE_NAMES);
expect(rules.every(r => r._source.provider === BUILTIN_DEFAULTS_PROVIDER_ID)).toBe(true);
});
it("parses every bundled rule as a TTSR rule (non-empty condition and scope)", async () => {
const rules = await loadBuiltinRules();
for (const rule of rules) {
expect(rule.condition?.length, `${rule.name} condition`).toBeGreaterThan(0);
expect(rule.scope?.length, `${rule.name} scope`).toBeGreaterThan(0);
}
});
it("parses YAML list-form conditions from the embedded text", async () => {
const rules = await loadBuiltinRules();
const lazylock = rules.find(r => r.name === "rs-lazylock");
// Frontmatter declares two condition patterns as a YAML sequence.
expect(lazylock?.condition).toHaveLength(2);
});
it("preserves a per-rule interruptMode override from frontmatter", async () => {
const rules = await loadBuiltinRules();
expect(rules.find(r => r.name === "ts-set-map")?.interruptMode).toBe("never");
});
it("is the lowest-priority rule provider so user/project rules override defaults", () => {
const { cap, provider } = ruleProvider();
const others = cap.providers.filter(p => p.id !== BUILTIN_DEFAULTS_PROVIDER_ID);
expect(others.length).toBeGreaterThan(0);
expect(others.every(p => p.priority > provider.priority)).toBe(true);
});
});
@@ -2,8 +2,8 @@ import { describe, expect, it } from "bun:test";
import {
normalizeTinyModelDevice,
resolveTinyModelDevicePreference,
tinyModelDeviceLoadOrder,
type TinyModelDevice,
tinyModelDeviceLoadOrder,
} from "../src/tiny/device";
function expectedDefaultDevice(): TinyModelDevice {