Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions .changeset/cli-version-pin-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@taskless/cli": patch
---

The CLI now runs only as the version a project records, and only on rules reconciled for its engines.

- **A CLI that is not the version `.taskless/taskless.json` records refuses every command except `init`, `info`, `auth`, and `feedback`**, with `CLI_VERSION_MISMATCH`. That covers npx, `pnpm dlx`, a `devDependencies` install, and any other way of starting it. Run commands through the project launcher, `./.taskless/bin/cli.mjs`, which runs the recorded version, and upgrade by running `init` with the newer CLI. If CI runs a pinned CLI, move the pin in the same commit as the upgrade.
- **`check` and `verify` refuse rules reconciled against a different ast-grep or Vale**, with `RULES_ENGINE_MISMATCH`, when `rules.engines` names an engine version this CLI does not bundle and the project has rules for that engine. Until now they ran the new engine against rules nobody had migrated. A rule still using a renamed kind could leave `check` reporting no ast-grep findings at all. When the rules are behind, run `update` and then `update --rules`, which applies the rule rewrites and records the bundled engines. A project that never recorded its engines is not affected.
- `--allow-version-mismatches` skips both refusals, and `TASKLESS_ALLOW_VERSION_MISMATCHES=1` does the same from the environment.
- npx is now only for installing, upgrading, and logging in (`auth` runs on any version). The `.taskless/README.md` written for new projects runs `check` through the launcher and names `init` for npx and `pnpm dlx`. `init`'s closing line says to run `./.taskless/bin/cli.mjs update` rather than the npx invocation that ran it, and the bare, non-interactive notice sends its reader to the launcher for recipes when the project has one.
6 changes: 4 additions & 2 deletions .taskless/rules/vale/docs-npx-cli/.tests/fail/README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
# Getting started

Run pnpm dlx @taskless/cli to install Taskless into this project.
Run npx @taskless/cli check to scan the project.

The inline form `pnpm dlx @taskless/cli@latest info` is a violation too:
The inline form `npx @taskless/cli@latest info` is a violation too:
commands in docs are almost always in code spans, so the rule has to see
them.

```bash
pnpm dlx @taskless/cli@latest check
npx @taskless/cli-nightly@0.13.0 agent route
yarn dlx @taskless/cli rule list
```

You can also invoke pnpm cli info to check the version.
9 changes: 7 additions & 2 deletions .taskless/rules/vale/docs-npx-cli/.tests/pass/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@

Run npx @taskless/cli to install Taskless into this project.

The inline form `npx @taskless/cli@latest info` is correct.
For a scripted install or upgrade, `npx @taskless/cli@latest init` runs the
same install without prompts, and so does `pnpm dlx @taskless/cli init`.

Logging in works from any version, so `npx @taskless/cli auth login` is fine.

```bash
npx @taskless/cli@latest check
./.taskless/bin/cli.mjs check
node .taskless/bin/cli.mjs check --json
npx @taskless/cli-nightly@latest --version
```

Other package managers are fine for unrelated work, such as pnpm install
Expand Down
17 changes: 15 additions & 2 deletions .taskless/rules/vale/docs-npx-cli/.vale.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# READMEs are read by external consumers, who do not have this repo's scripts.
# AGENTS.md and .conventions/ deliberately document the local `pnpm cli` path.
# A project runs one CLI: the version `.taskless/taskless.json` records, through
# the launcher `init` writes. Any other version refuses every command but a few,
# so a doc that tells its reader to run `npx @taskless/cli check` is telling
# them to run a CLI that will refuse. npx and pnpm dlx appear in docs only to
# install or upgrade, bare (the wizard) or with `init`, and to log in with
# `auth`, which runs on any version.
#
# READMEs are read by people who do not have this repo checked out, so they
# also never name the local `pnpm cli` script. AGENTS.md and .conventions/
# deliberately document it.
#
# There is no per-case escape hatch for this rule, and that is a property of its
# scope rather than an oversight. `scope: [raw, ...]` in the rule is what reaches
Expand All @@ -14,6 +22,11 @@
tskl) rule = docs-npx-cli
docs-npx-cli.docs-npx-cli = YES

# Read from outside the team exactly as a README is.
[dotagents.md]
tskl) rule = docs-npx-cli
docs-npx-cli.docs-npx-cli = YES

# Test fixtures are inputs to the CLI's own suite, not documentation.
[**/test/fixtures/**/README.md]
tskl) rule = docs-npx-cli
Expand Down
16 changes: 11 additions & 5 deletions .taskless/rules/vale/docs-npx-cli/docs-npx-cli.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
extends: substitution
message: "Use '%s' instead of '%s' — docs use the normalized invocation for external consumers"
extends: existence
message: "Run '%s' through the project launcher, ./.taskless/bin/cli.mjs. npx and pnpm dlx only install, upgrade, or log in: bare, init, or auth"
level: error
ignorecase: false
nonword: true
scope: [raw, code, text]
swap:
'pnpm dlx @taskless/cli': npx @taskless/cli
'pnpm cli': npx @taskless/cli
tokens:
# Every subcommand in packages/cli/src/commands/names.ts but `init` and
# `auth`, listed rather than matched as "any word that is not init", so prose
# such as "run npx @taskless/cli to install" passes. `auth` is allowed
# because a login is a per-repo token, not a version: the CLI runs it on any
# version.
- '\b(?:npx|pnpm dlx|yarn dlx|bunx) @taskless/cli(?:-nightly)?(?:@[\w.-]+)? +(?:agent|check|detect|feedback|info|onboard|report|rule|share|update|validate|verify)\b'
- '\bpnpm cli\b'
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
Thanks for working on Taskless.

This file sits outside every Vale scope in `.taskless/rules/vale/`, which is
deliberate. The `docs-npx-cli` rule rewrites `pnpm cli` and
`pnpm dlx @taskless/cli` to the published `npx @taskless/cli` form, at error
level, with a `raw` scope that reaches inside fenced code blocks and cannot be
deliberate. The `docs-npx-cli` rule refuses `pnpm cli`, and any npx or
`pnpm dlx` run of the CLI other than an install or an upgrade, at error level,
with a `raw` scope that reaches inside fenced code blocks and cannot be
suppressed with an in-file directive. That rule is right for a README, which is
read by people who do not have this repository checked out. It makes a
contributor guide impossible to write, because a contributor guide has to name
Expand Down
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,22 @@ scripts/

## Skill

Taskless ships a **single consolidated skill** (`taskless`). The skill body is a small router; per-task instructions live behind `npx @taskless/cli agent <topic>` and are fetched on demand. It handles any Taskless action (create rule, improve, delete, check, auth, CI) by fetching the canonical recipe for the user's intent and following it.
Taskless ships a **single consolidated skill** (`taskless`). The skill body is a small router; per-task instructions live behind `./.taskless/bin/cli.mjs agent <topic>` (the project launcher `init` writes) and are fetched on demand. It handles any Taskless action (create rule, improve, delete, check, auth, CI) by fetching the canonical recipe for the user's intent and following it.

Available `taskless agent` topics: `route`, `create-sg-rule`, `create-vale-rule`, `create-runtime-rule`, `create-remote-rule`, `improve-rule`, `delete-rule`, `check`, `auth`, `ci`, `info`, `init`, `update`. Run `taskless agent` with no topic for the index. Append `--anonymous` for the local-only flow on improve.

## CLI

The `@taskless/cli` package provides a CLI agent for Taskless workflows. It's recommended to always call the `latest` tag unless you know you need a specific version:
The `@taskless/cli` package provides a CLI agent for Taskless workflows. Install or upgrade a project with the `latest` tag:

```bash
npx @taskless/cli@latest info
npx @taskless/cli@latest init
```

That records the CLI version in `.taskless/taskless.json` and writes a launcher that runs exactly that version. Every other command goes through the launcher, since any other version of the CLI refuses to run in the project:

```bash
./.taskless/bin/cli.mjs info
```

## Local development
Expand Down Expand Up @@ -64,8 +70,10 @@ contributors and agents at while they are working in this repo. Nothing rebuilds
`dist/` for you, so run `pnpm build` first when you want current behavior.

This section names the script rather than spelling out its shell invocation, because
the `docs-npx-cli` rule holds every command in a README to the published
`npx @taskless/cli` form for readers who do not have this repo checked out.
the `docs-npx-cli` rule keeps the local script out of every README, which is read
by people who do not have this repo checked out. The same rule allows npx and
`pnpm dlx` only to install or upgrade, and sends every other command through the
project launcher.

## Releasing taskless/cli

Expand Down
14 changes: 8 additions & 6 deletions example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ They're build output.
## What `check` reports

```
$ npx @taskless/cli check
$ ./.taskless/bin/cli.mjs check

example.cjs:7:10
error[no-eval] Avoid eval. It executes whatever string it's handed.
Expand All @@ -72,16 +72,18 @@ severity, so this run exits 1 on the `error`.

## Running it yourself, in this repo

`npx @taskless/cli` fetches the published CLI. To run the one in this
checkout, build it first and then call it from here:
The commands on this page run `./.taskless/bin/cli.mjs`, the launcher `init`
writes into a real install, which runs the CLI version the project records.
This example ships without one. To run the CLI in this checkout, build it first
and then call it from here:

```
pnpm --filter @taskless/cli build # from the repo root
cd example
../packages/cli/dist/index.js check
```

Substitute that path for `npx @taskless/cli` in every command below.
Substitute that path for `./.taskless/bin/cli.mjs` in every command on this page.
Note that `check` writes the two assembled configs into `.taskless/`, so
expect them to appear after the first run. Both are gitignored.

Expand All @@ -90,8 +92,8 @@ expect them to appear after the first run. Both are gitignored.
`check` runs rules against your code. Two other commands run against the rules:

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

Both take a path: a rule directory, an engine directory, or nothing at all for
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-20
63 changes: 63 additions & 0 deletions openspec/changes/archive/2026-10-20-cli-version-pin-gate/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
## Context

See proposal.md for the motivation. The pieces this builds on already exist:

- `install.launchers` and `install.cliVersion` in `taskless.json`, and `recordedLauncherVersion()` in `install/launcher.ts`, which answers "what version does this package's launcher run". The gate asks the same question rather than defining a second notion of "the recorded version".
- `buildLauncherPackage()`, which names the package this build belongs to and returns `undefined` for a `self` build.
- `rules.engines`, written by `update --rules` (`recordReconciliation`) and nowhere else, so it is an honest record of what the rules were last reconciled against.
- `requireCurrentSchema()`, the scaffold-layout gate `check` and `verify` already call and already render as a JSON envelope or prose.
- `--allow-version-mismatches`, currently honoured only by the scaffold gate.

## Goals / Non-Goals

**Goals:**

- The CLI that runs a project's rules is the CLI the project recorded, and the rules were reconciled against that CLI's engines.
- Each refusal names a remedy that works from where the reader stands.

**Non-Goals:**

- Running migrations from `check`. `check` never writes to the rules tree (see `cli-check`), and the ledger walk can carry judgment a migration cannot.
- Protecting CLIs already published. They do not contain the gate. The engine gate's "ahead" branch exists for CLIs released after this one.
- Gating `validate`. It checks rule shape against this CLI's schema and is what an agent runs while mid-walk.

## Decisions

### The pin gate runs once, in the entry point, before dispatch

It goes in `index.ts` after the `--help`/`--version` intercepts and before `runCommand`, keyed on the first positional (`splitRawArguments`). One place means no subcommand can forget it, and the exemption list is visible in one spot. The alternative, a call in each command's `run`, is how `requireCurrentSchema` ended up with two call sites carrying different dead fallbacks.

Exempt: no subcommand, `init`, `info`, `auth`, `feedback`. `update` is NOT exempt even though it writes the manifest: it moves `rules.reconciledTo` and `rules.engines`, not the pin, so `npx @taskless/cli@latest update --rules` is exactly the call this exists to stop.

### "Recorded" means the version this package's launcher runs

`recordedLauncherVersion(buildLauncherPackage(), install.launchers, install.cliVersion)`. A release CLI compares against the release launcher, a nightly against the nightly one, so a project carrying both does not refuse either. When that returns `undefined` but the manifest records some version, the project is pinned to the other channel and the CLI refuses: this is the "any mismatch" choice, applied to channels too.

Not gated: no manifest, an unreadable manifest (the command it reaches reports `SCAFFOLD_MANIFEST_UNREADABLE` itself, with the right remedy), a manifest with no version, a `self` build, and a build whose version is `unknown`.

### The remedy depends on direction

Newer than recorded: run the launcher (`./.taskless/bin/cli.mjs`), or `init` with this CLI to move the project to it. Older than recorded: run the launcher. Suggesting `init` there would invite a downgrade. The launcher is named only when its file exists, since projects installed before launchers have none.

### The engine gate is a helper `check` and `verify` call next to `requireCurrentSchema`

`requireReconciledEngines(cwd)` in `rules/engine-gate.ts` throws a `CLIError` with `RULES_ENGINE_MISMATCH`, so both callers' existing handlers render it as an envelope or prose without new code paths. It is a separate function rather than a branch inside `requireCurrentSchema`, because `migrate.ts` must not grow a dependency on rule listing, and `update --rules` calls `requireCurrentSchema` and must not be refused by the engine gate it exists to clear.

An engine is compared only when it is recorded and the project has at least one rule for it (`listRuleIds`). Without the second condition a Vale bump would block every ast-grep-only project until it walked a ledger with nothing in it.

Comparison is by inequality, with `compareVersions` picking the message. Any difference is a mismatch: a patch-level engine bump can move matching semantics as easily as a minor one, which is why the reconciliation records exact versions.

### The override grows an environment form

`hasVersionMismatchOverride()` also reads `TASKLESS_ALLOW_VERSION_MISMATCHES` (`1` or `true`). Both gates call it. The root `cli` script sets it, because this repository's `pnpm lint` runs a fresh prod build against a manifest pinned to an older release and engines recorded several upgrades ago. That is the expected state of a development checkout, and CLAUDE.md already says `pnpm cli` is deliberately not the pin. An environment variable rather than a flag in the script, because a flag before the subcommand would have to survive citty's subcommand resolution.

## Risks / Trade-offs

- [A user upgrades with `init` and CI goes red on `check` until someone runs `update --rules`] → Intended: those rules have not been reconciled for the new engines. The refusal names both commands. Engine bumps are infrequent.
- [A `devDependencies` bump without `init` refuses everywhere] → Intended under "any mismatch". The message names `init`.
- [This repository's `pnpm lint` stops exercising the gate] → Unit tests cover the gate directly, and the dogfood run keeps exercising everything after it.
- [Tests that spawn the built CLI against fixture manifests recording other versions start failing] → Fixtures that record a version for reasons unrelated to the gate get updated to record none, or the test sets the override.

## Migration Plan

None beyond the release note. Rollback is reverting the change; nothing is written to disk by either gate.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
## Why

Engine upgrades no longer carry backward-compatibility weight: rule migrations rewrite whatever an ast-grep or Vale release breaks, so a project can always take the newest engines. What still breaks is running a CLI against rules reconciled for different engines, and nothing stops that today. The only version gate is the scaffold layout number, which rule migrations deliberately leave alone, so:

- a new CLI runs its new engines against rules nobody has migrated (the ast-grep 0.51.0 Haskell rename leaves `check` with no ast-grep findings at all), and
- an older CLI, typically a `devDependencies` pin in CI, runs rules that `npx @taskless/cli@latest update --rules` already migrated past it.

## What Changes

- **BREAKING (behavior)**: The CLI refuses to run any command other than `init`, `info`, `auth`, and `feedback` when its version is not the version the project recorded for its package (`install.launchers`, falling back to `install.cliVersion`). `init` is the one command that moves the pin, so upgrading becomes `init` and everything else runs on the recorded version. This applies however the CLI was started: npx, `pnpm dlx`, a `devDependencies` install, or a stale launcher. A project with no recorded version, and a `self` build, are not gated.
- `check` and `verify` refuse to run rules when `rules.engines` in `taskless.json` names an engine version different from the one this CLI bundles. Behind asks for the ledger walk and `update --rules`; ahead asks for a CLI upgrade. A project that never recorded engines is not gated.
- Two new error codes, `CLI_VERSION_MISMATCH` and `RULES_ENGINE_MISMATCH`, so an agent can tell the two remedies apart.
- `--allow-version-mismatches`, today the escape hatch for a scaffold newer than the CLI, also skips both new gates, and can be set as `TASKLESS_ALLOW_VERSION_MISMATCHES=1`. The repo's own `pnpm cli` script sets it, since a development build is deliberately not the pinned version.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `cli`: adds the version-pin gate, the engine-version gate, and the environment form of the mismatch override.

## Impact

- `packages/cli/src/index.ts` (gate before dispatch), a new `packages/cli/src/install/version-gate.ts`, `packages/cli/src/rules/engine-gate.ts`, `commands/check.ts`, `commands/verify.ts`, `filesystem/migrate.ts` (override reads the environment), `types/errors.ts`.
- Root `package.json` `cli` script.
- Any user whose CI runs a pinned CLI older than the project's recorded version gets a refusal naming the fix instead of a quietly wrong scan. Ships as `patch`: the package is pre-1.0.
- Delivery shape: **single PR**. The pin gate and the engine gate are one idea (the CLI that runs must match what the project recorded), and the whole change fits one reviewable diff.
Loading
Loading