Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f5b82dc
docs(openspec): propose self-contained Vale rules
thecodedrift Aug 14, 2026
321fc1e
docs(openspec): unify the rule layout across all three engines
thecodedrift Aug 14, 2026
83f1802
docs(openspec): record the legacy-path collision found while implemen…
thecodedrift Aug 14, 2026
b953f9f
docs(openspec): carry #103's state into the resume notes
thecodedrift Aug 14, 2026
d06413c
feat(cli): one directory per rule, with assembled engine configs
thecodedrift Aug 14, 2026
e1a5fd4
fix(cli): retarget Vale check names when 0005 splits the config
thecodedrift Aug 14, 2026
5e96917
test(cli): move the suites onto the rule-directory layout
thecodedrift Aug 14, 2026
7c0152e
test(cli): move migration, runtime, and vale-verify suites to the layout
thecodedrift Aug 14, 2026
244779f
test(cli): continue moving suites onto the rule-directory layout
thecodedrift Aug 14, 2026
36f7171
test(cli): green on the rule-directory layout, and pin the dot
thecodedrift Aug 14, 2026
ffc8f92
feat(cli): path-addressed verify and test
thecodedrift Aug 14, 2026
0f7a08a
feat: add example/, a Taskless install you can read
thecodedrift Aug 14, 2026
e2775d0
docs: rewrite the example's prose to Jakob's style guide
thecodedrift Aug 14, 2026
8a1ba02
docs(cli): rewrite the recipes for the rule-directory layout
thecodedrift Aug 14, 2026
87fffd5
test(cli): assert the migration behaviorally, and correct the changeset
thecodedrift Aug 14, 2026
ad74bd2
docs(openspec): record group 7 results and the archive ordering
thecodedrift Aug 14, 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
38 changes: 27 additions & 11 deletions .changeset/vale-rule-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,32 @@
"@taskless/cli": minor
---

Add Vale as a second static-tier rule engine.
Add Vale as a second static-tier rule engine, and give every engine one rule layout.

`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.
`check` now dispatches by engine and runs ast-grep, Vale, and runtime rules
concurrently, merging their findings into one result set. An unavailable Vale
reports itself and the other engines still return. A Vale that times out or
rejects its config fails the check rather than passing as a clean run.

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`.
**Every rule is now one directory**, `.taskless/rules/<engine>/<id>/`, holding
the rule, any per-engine config, and its tests in `.tests/`. Writing a rule
means creating a directory and deleting one means `rm -rf`. Nothing outside it
is touched either way, so concurrent authors never collide on a shared file.

Vale rules carry their own `.vale.ini` declaring which files they apply to.
The single config Vale reads is assembled from those per-rule files on each
run, gitignored, and regenerated, so hand edits to it have no effect. ast-grep
keeps its `files`/`ignores` inside the rule and needs no second file.

**`rule verify` is replaced by two path-addressed commands.** `verify <path>`
checks that a rule has the components its engine requires and needs no tests,
so it works while you're still authoring. `test <path>` runs the rule's tests,
after running `verify` and stopping if that fails. Both take a rule directory,
an engine directory, or nothing at all for the whole project, and both report
one result per rule. Addressing by path rather than id removes the ambiguity
that arose when two engines held the same rule id.

Agent recipes are rewritten for the layout, and `taskless agent route` now
carries the engine-selection reasoning that used to be its own topic.

Projects on an older layout migrate automatically on the next command.
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ __generated__

# Worktrees are second checkouts; formatting them would touch other branches
worktrees/

# The demo project: deliberately-wrong source and prose fixtures.
example/
5 changes: 5 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export default tseslint.config(
// Zero-dependency CommonJS workflow scripts (covered by their own
// node:test suite); the app's TS/ESM-oriented rules don't apply.
".github/scripts/",
// The demo project. Its source is deliberately wrong — `example.cjs`
// calls `eval` so a rule has something to find — and its fixtures are
// prose written to be flagged. Linting it fails on content nobody wrote
// as source. `example-project.test.ts` is what keeps it honest.
"example/",
],
},
eslint.configs.recommended,
Expand Down
2 changes: 2 additions & 0 deletions example/.taskless/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/.vale.ini
/.sgconfig.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
id: no-eval
valid:
- "JSON.parse(raw)"
- "const evaluate = () => 1"
invalid:
- "eval(raw)"
- 'eval("(" + raw + ")")'
9 changes: 9 additions & 0 deletions example/.taskless/rules/sg/no-eval/no-eval.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
id: no-eval
language: JavaScript
severity: error
message: Avoid eval. It executes whatever string it's handed.
note: |
`eval` runs arbitrary code with the caller's permissions. Parse the value
instead: `JSON.parse` for JSON, a real parser for anything else.
rule:
pattern: eval($$$ARGS)
3 changes: 3 additions & 0 deletions example/.taskless/rules/vale/no-simply/.tests/fail/hedged.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
You can simply drop a rule in.

Just run the check.
3 changes: 3 additions & 0 deletions example/.taskless/rules/vale/no-simply/.tests/pass/direct.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Drop a rule in, then run the check.

The adjustment took three releases.
6 changes: 6 additions & 0 deletions example/.taskless/rules/vale/no-simply/.vale.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Which files this rule applies to. Adding a rule edits nothing outside this
# directory. That's the point of the layout.
[*.{html,md}]
tskl) rule = no-simply
BasedOnStyles =
no-simply.no-simply = YES
7 changes: 7 additions & 0 deletions example/.taskless/rules/vale/no-simply/no-simply.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
extends: existence
message: "Avoid '%s'. It tells the reader the work was easy."
level: warning
ignorecase: true
tokens:
- simply
- just
3 changes: 3 additions & 0 deletions example/.taskless/taskless.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"version": 5
}
88 changes: 88 additions & 0 deletions example/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# A Taskless install, as it actually looks

This is a small project with Taskless rules in it. Everything here is real: the
same layout you get after installing, so you can read it before you commit to
anything.

Two rules, one per engine.

## The files

| Path | What it is |
| -------------- | ------------------------------------------------------- |
| `example.cjs` | A CommonJS module that calls `eval` on file contents |
| `example.html` | A page with a Title Case heading and some hedging prose |
| `.taskless/` | The rules. No build output, no cached state. |

## What a rule looks like

A rule is **one directory**. It holds everything that defines it. Adding a rule
means adding a directory. Removing one means removing that directory. No shared
file gets edited either way.

```
.taskless/rules/
sg/no-eval/
no-eval.yml the rule
.tests/no-eval-20260814-test.yml its test cases
vale/no-simply/
no-simply.yml the rule
.vale.ini which files it applies to
.tests/fail/hedged.md prose it must flag
.tests/pass/direct.md prose it must leave alone
```

Two details in there need explaining.

**`.tests/` is dot-prefixed on purpose.** ast-grep discovers rules by walking
the rules tree, and it reads every `.yml` it finds as a rule. A plain `tests/`
directory would make it parse the test files as rules and fail the whole scan.
A dot-directory gets skipped by that walk. The test runner still finds it.

**Only Vale has a per-rule `.vale.ini`.** Vale can't express "which files does
this apply to" inside the rule file, because it rejects unknown keys. Scope
needs somewhere else to live. ast-grep puts its equivalent (`files`, `ignores`)
inside the rule, so an `sg` rule gets no second file.

You won't find a project-wide `.vale.ini` or `sgconfig.yml` here. Both get
assembled from the per-rule configs when a check runs, and both are gitignored.
They're build output.

## What `check` reports

```
$ npx @taskless/cli check

example.cjs:7:10
error[no-eval] Avoid eval. It executes whatever string it's handed.
> eval("(" + raw + ")")

example.html:7:11
warning[no-simply] Avoid 'simply'. It tells the reader the work was easy.
> simply

2 issues (1 error, 1 warning) across 2 files
```

One finding from each engine, merged into one report. The exit code follows
severity, so this run exits 1 on the `error`.

## Checking the rules themselves

`check` runs rules against your code. Two other commands run against the rules:

```
$ npx @taskless/cli verify # are these rules well-formed?
$ npx @taskless/cli test # do they fire where they should, and only there?
```

Both take a path: a rule directory, an engine directory, or nothing at all for
everything. `test` runs `verify` first and stops if it fails. That way a broken
rule tells you what's broken.

## This example is tested

`packages/cli/test/example-project.test.ts` runs `check`, `verify`, and `test`
against this directory and asserts on what comes back. A demo that's drifted
from the layout it demonstrates is worse than no demo. If the layout changes
and this stops being true, the build fails.
14 changes: 14 additions & 0 deletions example/example.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// A small CommonJS module with something the ast-grep rule has to say about.
const { readFileSync } = require("node:fs");

function loadConfig(path) {
const raw = readFileSync(path, "utf8");
// `eval` on file contents is the pattern `no-eval` exists to catch.
return eval("(" + raw + ")");
}

function greet(name) {
return `Hello, ${name}`;
}

module.exports = { loadConfig, greet };
11 changes: 11 additions & 0 deletions example/example.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<title>Taskless example</title>

<h1>Getting Started With The Example</h1>

<p>
You can simply drop a rule into this project and run a check. The heading
above is Title Case, which the capitalization rule has an opinion about.
</p>

<p>Read the README for what each file is for.</p>
31 changes: 31 additions & 0 deletions openspec/changes/agent-command-and-vale-authoring/resume.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,37 @@ Also settled: pre-1.0, every backwards-incompatible change here is a **MINOR** b
never MAJOR. The telemetry event stays `cli_help` (agent-call volume stays visible under
the existing event).

## PR #103 is stacked above this one

`openspec/self-contained-rules` (branch still named `openspec/self-contained-vale-rules`),
based on this branch. **Spec-only and green** — proposal, design, four spec deltas, tasks.
`openspec validate self-contained-rules --strict` passes.

It unifies the rule layout across all three engines: one directory per rule at
`.taskless/rules/<engine>/<id>/` holding the rule, any config that engine needs, and its
tests in `.tests/`. Plus path-addressed `verify`/`test` replacing `rule verify <id>`, and
an `example/` project.

**Implementation is approved and not started.** Follow `self-contained-rules/tasks.md`.
One caveat that is easy to miss: task 1.5 deletes the legacy read paths, and it exists
because `.taskless/rules/` is simultaneously the new root and the old
`LEGACY_RULES_DIRECTORY`. That was found by starting the refactor, not by writing the
proposal — see design D9. The partial `engines.ts` rewrite was reverted rather than
pushed, so the PR stays spec-only; regenerating it is mechanical from D1/D2 and the
task list, and worth writing *against* D9 rather than patching D9 in afterwards.

Measured facts the implementation depends on, so nobody re-derives them:

- ast-grep `ruleDirs` **recurses**; `tests/` and `__tests__/` inside a rule directory
hard-fail the scan; **`.tests/` is skipped**, and `sg test` still reads it via `testDir`.
- Vale resolves `<id>/<id>.yml` as check `<id>.<id>` only under a `StylesPath` naming its
parent — nothing at all under `StylesPath = .`.
- Vale rejects unknown keys in a style (`E201`), so scope cannot live in the style file.
- A `.yml` sidecar in a style directory is loaded as a rule and fails; `.vale.ini` and
`.tests/` in the same place are ignored.
- Migrations run before any read (`ensureTasklessDirectory`), which is why the legacy
paths are unreachable rather than merely stale.

## Outside this PR

- **#99** — migrate subprocess handling to execa (inventory and sequencing already written up)
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/self-contained-rules/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-14
Loading