Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
714adf7
docs(openspec): add-vale-rule-engine — Vale as a second static-tier e…
thecodedrift Jul 27, 2026
9241027
docs(openspec): specify engine defaulting for service-delivered rules
thecodedrift Jul 27, 2026
962baea
docs(openspec): add engine-selection topic, path tolerance, binary re…
thecodedrift Jul 27, 2026
7165b89
docs(openspec): link add-vale-rule-engine to OSS-21
thecodedrift Jul 28, 2026
f5ef9c3
docs(openspec): scope add-vale-rule-engine to the Vale engine
thecodedrift Jul 28, 2026
58f667c
docs(openspec): declare add-vale-rule-engine as a merge-down stack
thecodedrift Jul 29, 2026
65d03e7
docs(openspec): record add-vale-rule-engine as a minor release
thecodedrift Aug 1, 2026
32dce95
docs(openspec): drop the Vale packaging subtasks now that the package…
thecodedrift Aug 10, 2026
88f6b6b
docs(openspec): tick the satisfied prerequisites, drop the missing se…
thecodedrift Aug 10, 2026
fd4bb6d
docs(openspec): settle the prompt export as TOPICS and fix the dangli…
thecodedrift Aug 10, 2026
2c6d5df
ref(cli): share the platform-binary resolver between ast-grep and Vale
thecodedrift Aug 10, 2026
89ba25f
feat(cli): run Vale over the committed config and map its findings
thecodedrift Aug 10, 2026
a3e6258
feat(cli): separate a skipped Vale from a failed one, guard the confi…
thecodedrift Aug 10, 2026
58a81e5
test(cli): pin Vale's behaviour, and correct the matcher precedence spec
thecodedrift Aug 11, 2026
90c8e50
chore(changeset): record the Vale rule engine at the bottom of the stack
thecodedrift Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/vale-rule-engine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@taskless/cli": minor
---

Add Vale as a second static-tier rule engine.

`check` now dispatches by engine directory and runs ast-grep, Vale, and runtime
rules concurrently, merging their findings into one result set. Vale rules live
in `.taskless/vale/` and execute against the committed `.vale.ini`; an
unavailable Vale reports itself and the other engines still return, while a Vale
that times out or rejects its config fails the check rather than passing as a
clean run. Vale rules are verified from `rule-tests/<rule>/pass|fail` fixtures
against a generated per-rule config.

Adds the `engine-selection` knowledge topic — which engine enforces a given
rule, and why — available from `taskless help engine-selection` and exported
through `@taskless/cli/prompts`.
Comment on lines +7 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changeset text describes work from units 2-4 of the stack that isn't in this PR's diff: concurrent multi-engine dispatch/merge in check (lines 7-8 — commands/check.ts isn't touched here, and tasks 2.1-2.3 are unchecked), fixture-based Vale rule verification (lines 12-13 — no rules/vale/verify.ts in this diff, task 1.4 unchecked), and the engine-selection help topic (lines 15-17 — no src/help/* changes here, tasks 3.1-3.7 unchecked).

Per the root CLAUDE.md's stacked-PR guidance ("One changeset, at the bottom of the stack, grown as the stack grows"): "Each PR extends the changeset with its own scope rather than the base describing the whole future change up front. A reviewer reading the changeset then sees only what has actually landed, and is not asked to evaluate a release note that promises more than the diff in front of them." As written, a reviewer of this PR alone sees a release note for three units of work that haven't landed yet. Worth trimming this changeset down to unit 1's scope (binary resolution, Vale runner, CheckResult mapping) and growing it in the follow-up PRs as the proposal itself describes.

2 changes: 2 additions & 0 deletions openspec/changes/add-vale-rule-engine/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-23
126 changes: 126 additions & 0 deletions openspec/changes/add-vale-rule-engine/design.md

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions openspec/changes/add-vale-rule-engine/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
## Why

Taskless rules are ast-grep only — structural AST matching that fits code but cannot lint prose. A large, wanted class of rules (terminology, banned and weasel words, inclusive language, readability, heading and style consistency) targets Markdown and prose, which ast-grep structurally cannot express: its text nodes are opaque, and it has no dictionaries, no NLP, and no code-vs-prose scoping. Vale is a mature, markup-aware prose linter that covers exactly that gap.

`partition-rules-by-engine` builds the layout this plugs into — `.taskless/<engine>/` with directory-based dispatch and a scaffolded but inert `vale/`. This change makes that directory execute, and adds the knowledge an agent needs to decide when a rule belongs there at all.

## What Changes

- Add Vale as a second **static-tier** engine: resolve its binary, run `vale --config .taskless/vale/.vale.ini --output=JSON --no-exit` over the target paths, and map findings into the scanner-agnostic `CheckResult` (stripping the `rules.` check-name prefix, normalizing severities).
- Express per-rule scoping through `.vale.ini` **matchers** (`[<glob>] rules.<name> = YES/NO`; enables union across matchers, disable wins, order-independent), with Taskless breadcrumbs carried as `tskl)`-namespaced keys Vale accepts and silently ignores.
- Verify Vale rules against per-rule `pass/`/`fail/` fixture directories, generating an isolating `.vale.ini` at verify time rather than committing one.
- Run engines concurrently and merge their results into one set, with an unavailable engine reported rather than aborting the others.
- Add an **engine-selection** knowledge topic — which engine (`sg` / `vale` / `runtime`) can enforce a requested rule — kept distinct from `route`'s authoring destination and from trust tier.

## Capabilities

### New Capabilities

- `cli-vale-rule-engine`: Vale as a concrete static engine — static-tier trust (its Tengo `script` sandbox exposes only `text`/`math`/`fmt`), the `.vale.ini` matcher scoping model, the `--config` check runner bounded by a subprocess timeout, fixture-based verify, and findings → `CheckResult` mapping.

### Modified Capabilities

- `cli-rule-format`: Vale's layout maps to `StylesPath` via the `rules/` StyleName — the directory `partition-rules-by-engine` scaffolds becomes the style Vale actually loads.
- `cli-check`: Engines run concurrently and their results merge into one set; an unavailable engine is reported without aborting the rest.
- `cli-rule-routing`: Gains an engine-selection topic, as an axis distinct from authoring destination and from trust tier. Ambiguity defaults to an engine known to be available.
- `cli-help`: The engine-selection topic is registered in the help index, and `route`/`static` cross-reference it so the local flow applies the same engine test the service does.

## Impact

- **CLI (`packages/cli`)**: new `rules/vale/*` (binary resolution, runner, `CheckResult` mapping, fixture verify); `commands/check.ts` (concurrent multi-engine execution and merge); new `src/help/<engine-selection>.txt` plus help-index registration and `route`/`static` cross-references.
- **Prerequisites**: `partition-rules-by-engine` must land first — this executes a directory that change creates. `add-vale-binary-packages` must land first as well, or there is no binary to resolve.
- **New external dependency**: the Vale binary, delivered as per-platform packages rather than assumed on `PATH`.
- **Deliberately excluded**: generating Vale rules and authoring the committed `.vale.ini`. That is rule-generation work, split between a CLI-side authoring change and server-side generation in the platform repo.

## Delivery shape

**Release impact: minor.** Adds a second rule engine and a new knowledge topic. Nothing existing breaks; ast-grep rules behave exactly as before. A whole new capability is a feature.

**Stacked, merging down.** The Vale engine is only correct once `check` can dispatch to it: the runner without the multi-engine orchestration ships an engine that never executes, and the orchestration without the runner dispatches to nothing.

| Unit | Scope |
| ---- | ---------------------------------------------------------- |
| 1 | Binary resolution, Vale runner, `CheckResult` mapping |
| 2 | Fixture-based verify |
| 3 | Concurrent multi-engine orchestration and merge |
| 4 | Engine-selection knowledge topic and its help registration |

Unit 4 is the arguable exception — a knowledge topic is inert and would be safe alone — but it describes `vale` as an engine an agent can choose, which is only true once units 1–3 exist. It rides the same stack rather than shipping guidance ahead of the capability.
**Tracking:** OSS-21
17 changes: 17 additions & 0 deletions openspec/changes/add-vale-rule-engine/specs/cli-check/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## ADDED Requirements

### Requirement: Check runs engines concurrently and merges their results

`taskless check` SHALL run its per-engine executors concurrently and merge their `CheckResult`s into a single result set. A missing or unavailable engine SHALL NOT abort the others; its absence SHALL be reported while the remaining engines still produce results.

#### Scenario: ast-grep and Vale run concurrently and merge

- **WHEN** `.taskless/sg/` and `.taskless/vale/` both contain rules
- **THEN** the CLI runs both engines concurrently and returns one merged result set whose findings count toward the exit code identically

#### Scenario: One engine unavailable, others proceed

- **WHEN** the `vale` binary is unavailable but `.taskless/sg/` has rules
- **THEN** the CLI reports the Vale engine as unavailable and still returns ast-grep results

## MODIFIED Requirements
24 changes: 24 additions & 0 deletions openspec/changes/add-vale-rule-engine/specs/cli-help/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## ADDED Requirements

### Requirement: The engine-selection topic is registered in the help system

The help system SHALL register the engine-selection recipe as an embedded help topic, retrievable via `taskless help <topic>` and listed in the help index, consistent with the existing topic embedding and format requirements.

#### Scenario: Engine-selection topic resolves

- **WHEN** `taskless help` is run for the engine-selection topic
- **THEN** the recipe text SHALL be returned and an unknown-topic error SHALL NOT be raised

#### Scenario: Engine-selection topic appears in the index

- **WHEN** `taskless help` is run with no arguments
- **THEN** the topic index SHALL include the engine-selection topic so an agent can discover it

### Requirement: Routing recipes reference engine selection

The `route` and `static` recipes SHALL reference the engine-selection topic so an agent following the local authoring flow applies the same engine test the service applies, rather than assuming ast-grep.

#### Scenario: Local flow reaches engine selection

- **WHEN** an agent follows `route` to a destination that authors a Taskless rule
- **THEN** the recipe directs it to the engine-selection topic before the rule is authored
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## ADDED Requirements

### Requirement: Vale styles live under the rules StyleName

The system SHALL place Vale styles under `.taskless/vale/rules/` so that `rules` is Vale's StyleName, with `.vale.ini` configured `StylesPath = .` and `BasedOnStyles = rules`. The Vale check identifier `rules.<name>` SHALL be normalized to `ruleId = <name>` in results.

#### Scenario: Style resolution and identity

- **WHEN** a Vale style exists at `.taskless/vale/rules/no-simply.yml`
- **THEN** Vale loads it as `rules.no-simply`, and the CLI reports its findings with `ruleId` `no-simply`
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
## ADDED Requirements

### Requirement: An engine-selection topic states which engine can enforce a rule

The CLI SHALL provide a knowledge topic that decides, for a requested rule, **which engine can enforce it** — `sg`, `vale`, or `runtime` — valued as the engine's on-disk directory name. The topic SHALL define each engine by the information a rule fundamentally needs:

- **`sg`** — expressible as a pattern over a single file's syntax tree, including correlation between constructs within that same file via relational operators.
- **`vale`** — the target is prose or markup content rather than code structure.
- **`runtime`** — needs information no single file's syntax tree contains: cross-file consistency, import or call graph, comparison against a non-code file, file metadata, or values requiring normalization a static pattern cannot express.

The topic SHALL instruct that the decision follow from what the rule fundamentally needs rather than how the request was phrased, and that the reasoning be stated before the engine is named.

#### Scenario: Engine named for a single-file structural rule

- **WHEN** the topic is applied to a request expressible as a pattern over one file's syntax tree
- **THEN** it selects `sg`

#### Scenario: Engine named for a prose rule

- **WHEN** the topic is applied to a request targeting prose or markup content
- **THEN** it selects `vale`

#### Scenario: Engine named for a cross-file rule

- **WHEN** the topic is applied to a request requiring information beyond a single file's syntax tree
- **THEN** it selects `runtime`

### Requirement: Engine selection is a separate axis from authoring destination

The engine-selection topic SHALL decide only which engine enforces a rule, and SHALL NOT decide where the rule is authored — that remains the `route` topic's concern. Locally the two compose, `route` first and engine selection second.

The topic SHALL NOT describe login, reconciliation, or signing as inputs to the engine choice: `sg` and `vale` are both static-tier, and only `runtime` carries those concerns, so trust tier is a distinct axis from engine selection.

#### Scenario: Topic stays clear of authoring destination

- **WHEN** the engine-selection topic is applied
- **THEN** it names an engine and does not select among `existing`, `static`, or `remote` authoring destinations

#### Scenario: Trust tier is not an engine-selection input

- **WHEN** the topic distinguishes `sg` from `vale`
- **THEN** it does so on the prose-versus-structure axis, not on any auth, reconcile, or signing property, since both are static-tier

### Requirement: Available code context outranks the phrasing of the request

Where code or diff context is available, the engine-selection topic SHALL weigh the concrete syntactic form present in the repository above the wording of the request, since the same request routes differently depending on the form the code actually takes.

#### Scenario: Concrete form changes the engine

- **WHEN** a rule is statically correlatable in the form the repository actually contains
- **THEN** the topic selects `sg`
- **AND WHEN** the equivalent rule requires normalizing a captured value to match a declaration elsewhere
- **THEN** it selects `runtime`, despite an identically phrased request

### Requirement: Ambiguity defaults to an engine known to be available

When no engine is clearly indicated, the engine-selection topic SHALL default to `sg` and record why the call was close. The topic SHALL state this as a property — the default names an engine known to be available — rather than as a bare fact about `sg`, so it stays correct wherever an engine can be unavailable or a route withheld.

#### Scenario: Ambiguous request defaults to sg

- **WHEN** the available context does not disambiguate which engine can enforce a rule
- **THEN** the topic selects `sg` and states the reasoning that made the call close

#### Scenario: The default is never an unavailable engine

- **WHEN** an engine is unavailable in the current environment, such as the Vale binary being absent
- **THEN** the ambiguity default SHALL NOT name it
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
## ADDED Requirements

### Requirement: Vale runs in the static tier without reconciliation or signing

The system SHALL treat Vale as a static-tier engine — always run, with no server reconciliation or signature verification. Vale's `script` checks execute in a sandbox that exposes only pure-computation modules (`text`/`math`/`fmt`) with no host access, so a Vale rule is inert data equivalent in trust to a static ast-grep rule.

#### Scenario: Vale runs when anonymous

- **WHEN** the CLI runs a check while logged out or anonymous
- **THEN** Vale rules are executed the same as ast-grep static rules, with no reconcile or signing step

### Requirement: Vale check executes against the committed config over the target paths

The system SHALL run `vale --config .taskless/vale/.vale.ini --output=JSON --no-exit` over the resolved target paths, reading the committed config and styles as-is. The `.vale.ini` SHALL set `MinAlertLevel = suggestion` so that every finding surfaces to the client for normalization and filtering.

#### Scenario: Check runs Vale via the committed config

- **WHEN** the CLI runs a check and `.taskless/vale/` contains rules
- **THEN** it invokes Vale with `--config .taskless/vale/.vale.ini` over the target paths and parses the JSON output

#### Scenario: No Vale rules present

- **WHEN** `.taskless/vale/rules/` is empty
- **THEN** the CLI does not invoke Vale and produces no Vale findings

### Requirement: Per-rule scoping is expressed via Vale config matchers

The system SHALL express a Vale rule's scope through `.vale.ini` **matchers** — `[<glob>]` sections. Include is `rules.<name> = YES`, exclude is `rules.<name> = NO`.

Precedence is **positional**, and the system SHALL order matchers accordingly rather than relying on a disable to win on its own. Measured against Vale 3.17.1:

- Where two matchers both match a file, the **last** one wins for that rule.
- Where the same key is assigned twice inside one matcher — including across duplicate `[<glob>]` sections, which Vale merges — the **first** assignment wins.

A disable therefore SHALL be declared **after** the enable it narrows. Duplicate `[<glob>]` matchers SHALL be treated as merged, and a rule's scope SHALL NOT be expressed as a repeated assignment of the same key within one glob, since the later assignment is discarded.

#### Scenario: Duplicate matchers merge

- **WHEN** two `[*.md]` matchers each enable a different rule
- **THEN** both rules run on a matching `.md` file (Vale merges the matchers)

#### Scenario: Include scopes a rule to a path

- **WHEN** a rule is enabled only under `[marketing/**]`
- **THEN** the rule produces findings in `marketing/` files and none in `api/` files

#### Scenario: A later matcher overrides an earlier one

- **WHEN** a rule is enabled under `[marketing/**]` and then disabled under `[marketing/legacy/**]`
- **THEN** the rule fires in `marketing/` but not in `marketing/legacy/`

#### Scenario: Declaration order is significant

- **WHEN** the same two matchers are declared in the opposite order — `[marketing/legacy/**]` disabling first, `[marketing/**]` enabling second
- **THEN** the rule fires in `marketing/legacy/` as well, because the later enable wins; a disable does not take precedence on its own

### Requirement: Vale findings map to the scanner-agnostic CheckResult

The system SHALL map each Vale finding to a `CheckResult` with `source` `"vale"` and `ruleId` equal to the Vale check name with its `rules.` prefix stripped. Severity SHALL be normalized `error → error`, `warning → warning`, `suggestion → hint`. The system SHALL map `message` from `Message`, `note` from `Description`/`Link`, `range` from `Line`/`Span`, `matchedText` from `Match`, and `fix` from `Action` only when the action is populated.

#### Scenario: Finding maps to CheckResult

- **WHEN** Vale reports a finding `{Check: "rules.no-simply", Severity: "warning", Line: 3, Span: [1,7], Message: "Avoid 'simply'", Match: "simply"}` in `docs/a.md`
- **THEN** the CLI emits a `CheckResult` with `source` `"vale"`, `ruleId` `"no-simply"`, `severity` `"warning"`, `message` `"Avoid 'simply'"`, `file` `"docs/a.md"`, and a `range` derived from line 3 / span 1–7

### Requirement: A Vale check is bounded by a subprocess timeout

The system SHALL bound each Vale invocation with a timeout and, on expiry, terminate the process and report the timeout rather than hanging.

#### Scenario: Runaway Vale invocation is terminated

- **WHEN** a Vale invocation exceeds its timeout
- **THEN** the CLI terminates the process and reports a timeout for the Vale engine without hanging the overall check

### Requirement: A missing Vale binary is reported without failing other engines

When the `vale` binary cannot be found or invoked, the system SHALL report that the Vale engine is unavailable and continue running other engines, rather than aborting the entire check.

#### Scenario: Vale binary absent

- **WHEN** `.taskless/vale/` has rules but the `vale` binary is not installed
- **THEN** the CLI reports the Vale engine as unavailable with an actionable message and still returns ast-grep results

### Requirement: Vale rules are verified with per-rule fixture subdirectories

The system SHALL verify a Vale rule from a `.taskless/vale/rule-tests/<rule>/` subdirectory containing `pass/` and `fail/` fixture documents. Because verification is one-time (not per-check), the system SHALL **generate** an ephemeral `.vale.ini` at verify time (StylesPath plus only that rule enabled) rather than requiring a committed one — the subdirectory holds fixtures only. Verification SHALL assert that every `fail/` fixture produces at least one finding for the rule and every `pass/` fixture produces none (mirroring ast-grep's `invalid`/`valid`).

#### Scenario: Fail fixture triggers, pass fixture does not

- **WHEN** verify runs for a rule and generates an isolating `.vale.ini` enabling only that rule
- **THEN** verification passes because every `fail/` fixture yields a finding and every `pass/` fixture yields none

#### Scenario: Verification fails when a fail fixture does not trigger

- **WHEN** a `fail/` fixture for a rule produces no finding
- **THEN** verification reports a failure for that rule

### Requirement: Taskless breadcrumbs use a namespaced ignored key in the Vale config

Any Taskless-owned breadcrumb the system records in `.vale.ini` SHALL use a `tskl) <name> = <value>` key. The system SHALL NOT rely on Vale enforcing these keys; they are read only by Taskless tooling, and Vale's ini parser accepts and ignores them. Each Taskless-owned matcher SHALL carry a `tskl) rule = <id>` key naming its owning rule, so tooling can locate and update the right rule's matchers even when its scoping is split across multiple (possibly duplicate) matchers.

#### Scenario: Breadcrumb key is ignored by Vale

- **WHEN** `.vale.ini` contains a `tskl) rule = no-simply` key
- **THEN** Vale runs normally, ignoring the key, and Taskless tooling can read it back

#### Scenario: Canonical id locates a rule's matchers

- **WHEN** a rule's scoping spans several matchers each tagged `tskl) rule = no-simply`
- **THEN** tooling can find every matcher owned by `no-simply` by its `tskl) rule` id rather than by glob
Loading
Loading