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
2 changes: 2 additions & 0 deletions .changeset/cli-v2-rule-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ What you may need to react to:

New: `taskless rule restore <ruleId>` repairs a rule to its current revision, and `taskless rule rollback <ruleId> <revisionId>` makes an earlier revision current. Both verify every file before writing it. On a plan that does not include rule recovery, they print how to recover the rule from your git history instead.

Upgrading also tidies a `.taskless/` that an earlier migration left half-moved: test files still sitting in `.taskless/sg/rule-tests/` (from rules whose ids ended in a timestamp) are moved into their rule's `.tests/`, and the empty legacy directory is removed. A test that matches no rule is left where it is.

Rules generated by 0.11.x or earlier were issued through the v1 API and are treated as locally written: ast-grep and Vale rules keep running, and runtime rules need to be regenerated.
2 changes: 1 addition & 1 deletion .taskless/taskless.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": 9,
"version": 10,
"install": {
"targets": {
".taskless": {
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ This is not hypothetical. An agent followed `pnpm cli agent create-sg-rule` from

**The installed Taskless skill pins a published nightly**, recorded as `install.cliVersion` in `.taskless/taskless.json`, and every command in `.taskless/skills/taskless/SKILL.md` carries that pin. The pin and `pnpm cli` disagree exactly when `dist/` is behind HEAD, and neither is automatically right: the pin is a real build of some commit, `pnpm cli` is this tree only after you rebuild it. Rebuild, then prefer `pnpm cli`: it is the only one that can reflect uncommitted work. Note that the nightly package is blocked by a deny rule here, so `pnpm build` is the practical way to get current recipes, not a fallback.

**Before `pnpm cli` talks to the Taskless service, build with `pnpm build:next`, not `pnpm build`.** The service and the rule generator decide what a client may do from its `x-taskless-cli-version` header: which API it may call, and whether it may be sent runtime rules. A plain build reports the last RELEASED version from `package.json`, so a tree carrying unreleased API work presents itself as the old client and trips the "unsupported" and upgrade gates meant for one. `build:next` stamps the version the pending changesets will release, as `<next>-next-<sha>` (for example `0.12.0-next-fa9ae7c`), which the service reads as that release. It builds the same nightly target CI publishes, so the only difference from a real nightly is the suffix. `pnpm lint` runs a plain `pnpm build`, so rebuild with `build:next` after linting if you are about to call the service.

When running OpenSpec commands in this repo, use `pnpm openspec` instead of a bare `openspec`. The bare command is not on `PATH` here and is blocked by a deny rule.

## Git Command Help for Agents
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,44 @@ vale, which run whatever the verdict for `unknown`.

`verify` and `test` keep assembling from the live tree at the existing paths.

### 2a. Every run gets its own run directory, removed when the run ends

The snapshot started as one shared `.taskless/.run/snapshot/`, replaced at the start of every
run. Two concurrent runs (a pre-commit hook during an editor's on-save `check`) then shared
it: the second deleted and re-copied the tree the first was still reading, so the first could
run a half-copied tree, or a copy whose signatures it never checked. That is the exact failure
the snapshot exists to prevent.

So each run works in `.taskless/.run/<runId>/`: `<timestamp>-<random>`, sortable and unique.
It holds the snapshot, the assembled configs, an `owner` record (pid, host, start time), and
four logs: `engine.log` (the plan), `sg.log`, `vale.log`, `runtime.log`. It is removed in a
`finally`, and synchronously on SIGINT/SIGTERM before the signal is re-raised.
`--preserve-logs` (`-l`) keeps the WHOLE directory rather than only the logs, because the logs
name files by their snapshot paths and "what exactly ran" is usually the question
(product decision, 2026-09-29).

A SIGKILL skips all cleanup, so every run first sweeps directories whose owner process is gone
on this host, plus ownerless ones left by earlier versions (0.11's `runtime-rules/`). Liveness,
not age, is the test (product decision): an age limit either deletes a slow live run or keeps
junk for hours. A directory owned by another host is left alone; that only arises on a shared
filesystem, and this host cannot tell whether the process lives.

`.taskless/.run/.gitignore` (`*`) is written only if missing, since concurrent runs would race
on it.

Two defects the concurrency test found once it was run repeatedly, both fixed:

- **Vale walked other runs' directories.** Vale's `--glob` exclusion filters which files it
lints, not where it walks, so it `lstat`ed directories other runs were deleting and died
with `E100` (4 of 24 concurrent runs lost every Vale finding). A whole-project Vale run is
now handed each top-level entry except `.taskless/` and `.git/` instead of `.`, so it never
enters `.taskless/`. ast-grep's walker honors the nested `.gitignore` and needed nothing.
Measured after: 0 of 48.
- **A starting run looked abandoned.** A run creates its directory and writes `owner` as two
steps, so a concurrent sweep could see a live run with no owner. Only non-run-id names
(the legacy `snapshot/`, `runtime-rules/`) are swept for being ownerless; a run-id
directory without an owner is swept only after a one-minute grace.

### 3. What is reported for a rule

One `{ ruleId, files }` per directory under `.taskless/rules/<engine>/`, where
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ Slices, each targeting the one below it:
4. **Recovery**: `rule restore`, `rule rollback`, the refusal.
5. **Retire v1**: delete v1 code and tests, recipes, the end-to-end round trip
against production, and the archive.
6. **Run directories**: a per-run `.taskless/.run/<runId>/` holding the
snapshot and engine logs, removed when the run ends unless
`--preserve-logs`, with abandoned ones swept. Fixes concurrent `check`s
sharing one snapshot.

The changeset is `minor` and lives on slice 1. Two reasons, either sufficient:
`check` now fails on an edited sg or vale rule, and `rule create --json` renames
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,67 @@ name the command that repairs the rule, `taskless rule restore <ruleId>`. The on
- **THEN** `check` SHALL NOT call any restore or fetch endpoint
- **AND** SHALL NOT create the rule's directory

### Requirement: Each check run works in its own run directory

`taskless check` SHALL do its work (the snapshot, the assembled engine configs, and its logs)
in a directory of its own, `.taskless/.run/<runId>/`, where `<runId>` is unique per run and
sorts by start time. Two runs SHALL never share a run directory. The directory SHALL hold an
`owner` record naming the process and host using it, and the logs `engine.log` (the plan: what
was copied, reported, judged, excluded, and run), `sg.log` and `vale.log` (each engine's
command line, output, and exit code), and `runtime.log` (each runtime rule's duration,
findings, and any error). No log SHALL contain a credential. The CLI SHALL remove the run
directory when the run ends, whether it succeeded or failed, and on SIGINT or SIGTERM, unless
`--preserve-logs` is set. `.taskless/.run/` SHALL ignore itself with its own `.gitignore`, so
that no run rewrites a tracked file. `rule restore` SHALL take its snapshot the same way.

#### Scenario: Concurrent runs do not disturb each other

- **WHEN** several `taskless check` runs execute at the same time in one project
- **THEN** each SHALL report the same findings it reports alone
- **AND** none SHALL read another's snapshot

#### Scenario: Nothing is left behind

- **WHEN** a `taskless check` run ends, successfully or not, without `--preserve-logs`
- **THEN** its run directory SHALL no longer exist

### Requirement: Check accepts --preserve-logs to keep its run directory

`taskless check` SHALL accept `--preserve-logs` (alias `-l`), which keeps the run directory
instead of removing it: the snapshot that ran, the assembled configs, the `owner` record, and
the logs. Human output SHALL name the kept directory on stderr. Under `--json`, the output
SHALL carry an additive, optional `runDirectory` field, the directory's path relative to the
project root, present only when the flag is set.

#### Scenario: A preserved run is named and complete

- **WHEN** a user runs `taskless check --json --preserve-logs`
- **THEN** stdout SHALL include `runDirectory`
- **AND** that directory SHALL hold `engine.log`, `sg.log`, `vale.log`, `runtime.log`, `owner`, and the snapshot

#### Scenario: A preserved authenticated run holds no credential

- **WHEN** an authenticated `check --preserve-logs` reconciles
- **THEN** no file in the kept run directory SHALL contain the token

### Requirement: Abandoned run directories are swept

At the start of every run, the CLI SHALL remove each directory under `.taskless/.run/` whose
`owner` names a process on this host that is no longer alive, and each directory with no
`owner` record (left by an earlier version). It SHALL NOT remove a directory whose owning
process is alive, or one owned by another host, since this host cannot tell whether that
process lives. A directory's age SHALL NOT be the test.

#### Scenario: A killed run's directory is swept

- **WHEN** a run directory's `owner` names a process on this host that has exited
- **THEN** the next run SHALL remove it

#### Scenario: A live run is never swept

- **WHEN** a run directory's owning process is still running, or it is owned by another host
- **THEN** no other run SHALL remove it

### Requirement: Check reports rule integrity under --json

Under `--json`, `taskless check` SHALL carry an additive, optional `integrity` array with one
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,9 @@ outside this check.

### Requirement: The CLI runs the bytes it reported

Before signing anything, `check` SHALL copy `.taskless/rules/` into a snapshot under
`.taskless/.run/`, replacing any previous snapshot and dereferencing symbolic links.
Before signing anything, `check` SHALL copy `.taskless/rules/` into a snapshot inside its own
run directory under `.taskless/.run/` (per the `cli-check` capability), dereferencing symbolic
links.
It SHALL compute every reported signature from the snapshot and SHALL run every engine
from the snapshot, with the assembled configs written under `.taskless/.run/`. A rule the
verdict excludes SHALL be removed from the snapshot before any engine configuration is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,18 +182,57 @@ upgradeUrl }`, strip C0/C1 control characters except newline from
- [x] 8.4 Run `pnpm typecheck` and `pnpm lint` (which rebuilds and runs
`pnpm cli check`) from the repository root; both pass.

## 10. Run directories (slice 6)

- [x] 10.1 Add `rules/run-directory.ts`: `.taskless/.run/<runId>/` per run with an
`owner` record, four logs, removal on close and on SIGINT/SIGTERM, a sweep of
directories whose owner is gone (or that have no owner), and a self-ignoring
`.taskless/.run/.gitignore` written only if missing. Unit tests cover unique
ids, close, preserve, sweeping a dead owner, sparing a live or foreign one,
and the legacy ownerless directories.
- [x] 10.2 Move the snapshot into the run directory; `check` and `rule restore`
each open a run and always close it.
- [x] 10.3 Thread the logs through: ast-grep and Vale take an optional `log`
(command line, raw output, exit), dispatch times each runtime rule into
`runtime.log`, and the planner writes the plan to `engine.log`.
- [x] 10.4 Add `check --preserve-logs` / `-l` and the `--json` `runDirectory`
field. End-to-end tests: nothing left behind, four concurrent checks match a
lone check, a preserved run holds the logs and snapshot, and no log from an
authenticated run contains the token.

- [x] 10.5 Found by running the concurrency test repeatedly: keep Vale's
whole-project walk out of `.taskless/` (it `lstat`s directories other runs
delete), and give an ownerless run-id directory a grace period before it
is swept. 0 of 48 concurrent runs fail, against 4 of 24 before.
- [x] 10.6 Found by the production round trip: `engine.log` records each rule's
verdict (a local `unknown` and a `run` both run and read the same
otherwise), and a restore refusal no longer repeats an upgrade link the
service already wrote into its message.

- [x] 10.7 Found by the production round trip: migration 10 moves ast-grep tests
that 0005 left in `.taskless/sg/rule-tests/` (rules whose ids end in a
timestamp, tested as `<id>-test.yml`) into the matching rule's `.tests/`,
by longest matching rule id, never overwriting, leaving anything it cannot
match. Verified on the sandbox clone that exposed it; this repository's
scaffold moves to version 10.

## 9. End to end, then archive (slice 5)

- [ ] 9.1 From a nightly stamped `0.12.0-*`, against production v2, run the full
round trip in an organization we own: `rule create` → `check` shows `run`
→ edit a signed file → `check` fails with `unsafe` → `rule restore` →
`check` shows `run`. Repeat the edit on a vale rule's `.vale.ini`. Record
commands and outputs in the PR.
- [ ] 9.2 Report the round trip to the cloud team so they can close TSKL-307.
- [ ] 9.3 File the follow-ups as issues: a rule-revisions list endpoint (enables
rollback without the dashboard), plan features on `whoami`, the
directory-swap gap, the superseded-revision signal, and the v1
`Entitlement` type on served file sets.
- [ ] 9.4 Archive the change on the tip branch (`pnpm openspec archive
- [x] 9.1 Round trip against production v2 from a `0.12.0-next-*` build (`pnpm
build:next`; a nightly publishes only from `main`, which the stack reaches
last), in `taskless-sandbox/nextjs-sass-starter` because the `taskless`
installation did not then cover `taskless/cli`. Generated two sg rules and
one Vale rule; `check` returned `run`; a loosened sg pattern and a
disabled `.vale.ini` each returned `unsafe` and failed the run; `rule
restore` on the Free plan returned the git-recovery refusal and wrote
nothing; the issued bytes put back returned `run`. The served-restore
success path needs a paid plan and is covered by stubbed tests only.
- [x] 9.2 Round-trip report drafted for the cloud team to close TSKL-307 (sent
by hand). The Vale `BasedOnStyles =` defect it found is
taskless/taskless#258.
- [x] 9.3 Follow-ups filed: taskless/taskless#252 (v1 `Entitlement` on served
file sets), #253 (rule revisions listing), #254 (plan features on
`whoami`), #255 (directory-swap gap), #256 (superseded-revision signal).
- [x] 9.4 Archive the change on the tip branch (`pnpm openspec archive
cli-v2-rule-api`), then re-run the scenario-survival check from 1.1
against the archived specs.
Loading
Loading