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/pinned-cli-wrapper.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@
`init` now installs a project launcher, `.taskless/bin/cli.mjs` (`cli-nightly.mjs` for a nightly), that runs the CLI version recorded for it in `.taskless/taskless.json`. The installed skill and recipes fetched through the launcher name it instead of a pinned `npx` call, so the skill no longer changes on every release, and every harness's permission file gets a version-free rule for it. The `ci` recipe now generates `node .taskless/bin/cli.mjs check`, with Node supplied by the job, so a repository without a `package.json` can run Taskless in CI. The `hooks` recipe runs the launcher too, instead of adding `@taskless/cli` as a dev dependency. When the launcher started the CLI, the commands it prints for you to run next name the launcher, and `.taskless/README.md` lists it first.

Each package's Claude Code `autoMode.allow` entry now also covers its own launcher, so one time only, the wizard offers to replace the existing entry. More than one string under a package's label now counts as stale, and the wizard folds them into one. Settings edits are colored as migrations: `ALLOW` green, `REMOVE` uncolored, `REPLACE` yellow, and `SKIPPED` red, which marks an edit you have to make yourself. A missing or stale global entry after `init` prints as a red `SKIPPED (global):` line.

The launcher no longer runs the CLI through `npx`. The first run of a recorded version installs it into `.taskless/.cache/` with `npm ci`, from a lockfile `init` writes beside the launcher (`.taskless/bin/cli.lock.json`, or `cli-nightly.lock.json`). Later runs start it from there without the network. Each release derives that lockfile from the dependency tree its CI tested, so every machine installs the same versions, `tsx` and `esbuild` included, where a fresh `npx` install resolves whatever their ranges allow that day. Commit the lockfile with the launcher; the cache ignores itself in git, and `init` and the launcher remove versions nothing records any more. The install uses the nearest `.npmrc` at or above the project, so a private registry keeps working. The `ci` recipe now caches `.taskless/.cache` between runs.
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,4 +345,7 @@ Every scenario present before must still be present, plus whatever you added. Tw

## OpenSpec Apply

When implementing changes via `/opsx:apply`, **pause after each task group** for user review before continuing. Commit between groups and wait for confirmation.
When implementing changes via `/opsx:apply`, **work through every task group without pausing for review**, and report once, after the whole stack is done: every group implemented, the checks passing, the change archived, and each PR in the stack open.

- **Commit after each task group**, one signed commit per group, so a reviewer can read the change a group at a time. Running `/opsx:apply` is the confirmation for these commits; the general rule to wait before committing does not apply to them.
- **Stop early only for a real blocker:** a task the artifacts leave ambiguous, an implementation finding that changes the spec or the design, or a failure you cannot resolve. Then say what you found and what you need. A finding you can resolve yourself, such as a bug the end-to-end check catches, is fixed, recorded in the artifacts, and reported with everything else at the end.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-19
94 changes: 94 additions & 0 deletions openspec/changes/archive/2026-10-19-launcher-local-cache/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
## Context

The launcher is a template, `packages/cli/assets/launcher/launcher.mjs`, rendered once per package and written to `.taskless/bin/cli.mjs` (release) or `.taskless/bin/cli-nightly.mjs` (nightly). It reads `install.launchers[<package>]` from `.taskless/taskless.json`, falls back to `install.cliVersion` when that version belongs to its package, and runs `npx --yes <package>@<version>`.

`@taskless/cli` does not run from its own tarball. It depends on `tsx` (a range), and through it on esbuild, `get-tsconfig` and esbuild's per-platform binary, and on seven ast-grep and six Vale per-platform optional packages (exact versions). Runtime rules run through that `tsx`.

Measurements behind the decisions below, all on 2026-10-19 with npm 11.8 on macOS arm64:

| Install | Result |
| ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| `npm install @taskless/cli@0.13.1` into an empty prefix | tsx 4.23.15, esbuild 0.28.x; only darwin-arm64 binaries; 110 MB |
| Same, from a registry serving a repacked 0.13.1 with a full `npm-shrinkwrap.json` | tsx 4.21.0, esbuild 0.27.3, but every platform's binaries; 1.0 GB |
| Same, shrinkwrap without the platform packages | tsx 4.21.0, no ast-grep, Vale or esbuild binary; 2.3 MB, broken |
| `npm ci` from a constructed lockfile (deps from the pinned tree, CLI entry without `integrity`) | tsx 4.21.0, esbuild 0.27.3, get-tsconfig 4.13.6; only darwin-arm64 binaries; 109 MB; 0.6 s |
| `npm ci` with that lockfile and a `package.json` asking for another version | refused: `EUSAGE`, lockfile and `package.json` not in sync |
| `npm config get registry` in a project with a `.npmrc`, with and without `--prefix .taskless/.cache/x` | the project's registry without `--prefix`; the public registry with it; the project's again once the `.npmrc` is copied into the prefix |

## Goals / Non-Goals

**Goals:**

- A launcher install is the dependency tree we tested, on every machine.
- After the first run of a recorded version, a Taskless command touches neither the network nor npm.
- The installed tree and the lockfile that produced it are inspectable inside the project.
- Private registries configured in the project's `.npmrc` keep working.
- The cache never holds more than the versions the manifest records.

**Non-Goals:**

- Freezing `npx @taskless/cli` or `pnpm dlx` installs. They keep resolving ranges.
- Changing the CLI's `package.json` at build or publish time.
- A cache shared across projects.

## Decisions

### The lockfile is derived from `pnpm-lock.yaml` at build time, offline

`pnpm-lock.yaml` is what CI installs and tests against, and it already holds what an npm lockfile needs: each package's `integrity`, its `os`, `cpu` and `optional` markers in `packages:`, and the resolved dependency graph in `snapshots:`. The nightly and release builds walk the CLI importer's production dependencies (`dependencies` and `optionalDependencies`) through `snapshots:` and emit a version 3 npm lockfile:

- `""`: `{ dependencies: { <package>: <version> } }`, the same one-line `package.json` the launcher writes.
- `node_modules/<package>`: version, registry `resolved` URL, and the package's own `dependencies`, `optionalDependencies`, `bin` and `engines`. No `integrity`: the lockfile ships inside this tarball, so it cannot hold the tarball's hash.
- `node_modules/<dep>` for every package in the walk, flat, with `resolved`, `integrity`, `os`, `cpu`, `optional` and `dependencies` from `pnpm-lock.yaml`.

The build fails if two packages in the walk share a name at different versions, since a flat layout cannot hold both and a nested one is a different problem to solve when it appears. It also fails if a package in the walk has no integrity in `pnpm-lock.yaml`. `yaml` is already a dev dependency of the CLI. The lockfile is embedded in the bundle as a string, the way recipes are, so `init` needs no file lookup and no network. A `self` build emits none.

`resolved` URLs name `registry.npmjs.org`. npm's default `replace-registry-host` rewrites that host to the configured registry, so a mirror still serves them.

Alternatives considered:

- **Publish an `npm-shrinkwrap.json`.** Measured above: 1.0 GB with platform packages, broken without them.
- **Rewrite `package.json` to exact versions at publish.** Rejected: the published manifest would differ from the repository's, and it would still leave transitive dependencies floating.
- **Resolve the lockfile at `init` time with npm, forcing the tested versions through overrides.** Needs the network at `init`, and npm's resolution, not our lockfile, decides the tree.

### One lockfile per package, beside its launcher

`init` writes `.taskless/bin/cli.lock.json` with `cli.mjs`, or `.taskless/bin/cli-nightly.lock.json` with `cli-nightly.mjs`, from the build that is running. It never writes the other package's pair. A lockfile whose bytes differ from this build's is rewritten and reported as restored, as the launcher is. Both files are committed, so an upgrade's dependency changes appear in the same diff as the version change.

### The launcher installs with `npm ci` into a temporary directory, then renames

On a cache miss the launcher:

1. Reads its lockfile. If it is missing, or its `node_modules/<package>` version is not the recorded version, it stops and says to rerun the installer. It never installs without the lockfile.
2. Creates `.taskless/.cache/.tmp-<pid>-<random>/`, writes `package.json` (`{ "private": true, "dependencies": { <package>: <version> } }`) and copies the lockfile in as `package-lock.json`.
3. Copies the nearest `.npmrc` at or above the project root into the temporary directory, if there is one. The walk stops short of the home directory, whose `.npmrc` npm already reads as user config. npm reads project settings only from the directory it installs into (measured in the table above), so without the copy a private registry would be ignored. The launcher finds the file itself rather than asking `npm prefix`: npm masks anything in its output that looks like a token, a UUID path segment included, so under such a path `npm prefix` printed `…/***/…`, and on 2026-10-20 the launcher then found no `.npmrc` and installed from the public registry without a word. The nearest-file rule also covers a monorepo package whose `.npmrc` sits at the repository root.
4. Runs `npm ci --ignore-scripts --no-audit --no-fund --loglevel=error` there, with npm's output on standard error so a first run's `--json` output stays clean.
5. Deletes the copied `.npmrc`, checks the package's `bin` entry resolves, and renames the directory to `.taskless/.cache/<package>@<version>/`. If that already exists, another process won: it deletes its own directory and uses the existing one.

The final directory's existence means a complete install; there is no lock file. A failed step removes the temporary directory and exits non-zero without running the CLI.

`--ignore-scripts` is safe because every native piece arrives as a per-platform optional package; the measured install ran ast-grep, Vale and the CLI without scripts.

The run is `spawnSync(process.execPath, [entry, ...args])`, no shell. `cmd.exe` quoting stays only for the two npm calls on Windows, whose arguments are fixed.

### The cache ignores itself

The launcher writes `.taskless/.cache/.gitignore` containing `*` when it creates the directory or finds it without one. No migration, no edit to `.taskless/.gitignore`.

### Pruning

An entry is kept when it is `<package>@<version>` for a version `install.launchers` records (or the `cliVersion` fallback the launcher would use). Everything else goes, including `.tmp-*` directories older than an hour. `init` prunes after recording versions; the launcher prunes after it installs a new version, which covers a teammate who pulls an upgrade and never runs `init`. Removal failures (a locked file on Windows) are ignored and retried next time.

## Risks / Trade-offs

- [The CLI's own tarball has no `integrity` in the lockfile] → It is pinned by exact version, and a published npm version cannot be republished with different contents. Every dependency is hash-pinned.
- [A future dependency tree needs nesting (two versions of one package)] → The build fails, so a release cannot ship a wrong lockfile; nesting support is added when that happens.
- [`pnpm-lock.yaml` and npm disagree about a package's metadata] → The launcher's `npm ci` validates the lockfile on every first run, and the end-to-end check below runs it before release.
- [A concurrent run of an old version while the launcher prunes it] → Pruning follows only a new install, which follows a manifest change; the window is one run.
- [About 110 MB per project per package version] → At most one version per package after pruning.
- [npm missing on a first run] → Same requirement as `npx` today; the error names npm and the version.
- [CI cache restored from another OS] → The key includes the runner OS, and the launcher trusts an entry only when its bin resolves.

## Migration Plan

The next `init` rewrites each launcher and writes its lockfile, both reported. Until then a #537 launcher keeps using `npx`. A new launcher without its lockfile (a manifest from before `install.launchers`) stops and asks for `init`. Rolling back is reinstalling the older CLI; a leftover `.taskless/.cache/` and lockfile are harmless and safe to delete.
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
## Why

The project launcher (#537) runs the CLI version recorded in `.taskless/taskless.json`, but every run still goes through `npx --yes <package>@<version>`. That puts npx's shared cache, its resolution rules and a registry lookup inside every Taskless command. It also leaves the CLI's own dependencies floating: a published package carries only its `package.json` ranges, so a fresh install of 0.13.1 on 2026-10-19 resolved `tsx` 4.23.15 and `esbuild` 0.28.x where we test 4.21.0 and 0.27.3 (#538).

A published `npm-shrinkwrap.json` cannot fix that for this package. Measured through a registry with npm 11.8: a full shrinkwrap installs every platform's ast-grep, Vale and esbuild binaries (1.0 GB instead of 110 MB), because npm unpacks a dependency's shrinkwrap without its platform check, and a shrinkwrap without the platform packages installs no binaries at all.

Installing the recorded version once into a git-ignored `.taskless/.cache/`, from a lockfile the release build derived from our own `pnpm-lock.yaml`, makes every run local and every install the tree we tested. npm is then only the download step, and the lockfile is committed in the project where a reviewer can read it.

## What Changes

- Each nightly and release build derives an npm lockfile for its own package from `pnpm-lock.yaml`, offline, and embeds it. The CLI's `package.json` keeps its ranges; development stays loose. A build whose dependency tree cannot be laid out as one flat npm tree fails rather than shipping a partial lockfile.
- `init` writes that lockfile beside the package's launcher, `.taskless/bin/cli.lock.json` for the release and `.taskless/bin/cli-nightly.lock.json` for the nightly. A nightly install leaves the release's pair alone, as it already leaves the release's launcher.
- The launcher installs into `.taskless/.cache/<package>@<version>/` with `npm ci --ignore-scripts` from its lockfile, into a temporary directory renamed into place once complete, then runs the installed bin with the same `node`. It no longer calls `npx`. A missing lockfile, or one that does not name the recorded version, stops the launcher with a message to rerun `init`.
- The install uses the nearest `.npmrc` at or above the project root, so private registries keep working. `npm install --prefix` alone ignores that file.
- The cache ignores itself (`.taskless/.cache/.gitignore` containing `*`). No scaffold migration.
- Cached versions neither package records are removed, by `init` and by the launcher after it installs a new version.
- The `ci` recipe caches `.taskless/.cache`, keyed on the runner OS and the hash of the package's lockfile.
- Not changed: `npx @taskless/cli` and `pnpm dlx` still resolve ranges. The launcher is the frozen path.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `cli-project-launcher`: the launcher installs from its lockfile into `.taskless/.cache/` and runs it there; published builds carry that lockfile; the cache ignores itself and drops unrecorded versions.
- `cli-init`: `init` writes the package's lockfile beside its launcher and prunes the cache.
- `cli-agent`: the `ci` recipe caches `.taskless/.cache` between runs.

## Impact

- `packages/cli/assets/launcher/launcher.mjs`: lockfile install and local run replace the `npx` call. The next `init` rewrites existing launchers and reports them restored.
- `packages/cli/vite.config.ts` (or a build script it calls): derive the lockfile from `pnpm-lock.yaml` for nightly and release targets, and fail the build when it cannot.
- `packages/cli/src/install/launcher.ts` and the `init` flow: write the lockfile, prune the cache.
- `packages/cli/src/agent/ci.md`, `packages/cli/src/agent/hooks.md`, `skills/taskless/SKILL.md`: cache step and first-run wording.
- Projects gain a committed lockfile per installed package (about 22 KB) and a git-ignored cache, about 110 MB per version on macOS arm64, at most one version per package. npm must be on `PATH` for each version's first run, as it already is for `npx`.
- #538 is resolved by this change rather than by pinning `tsx` in `package.json`.
- Delivery shape: single PR, stacked on #537, merging forward once #537 lands. It depends on the launcher and is safe on its own after it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## MODIFIED Requirements

### Requirement: The ci recipe runs the launcher with Node supplied by the job

The `ci` recipe SHALL generate CI configuration that runs `node .taskless/bin/cli.mjs check`, for a full scan, and `node .taskless/bin/cli.mjs check <files>`, for a diff scan, from the repository root. It SHALL NOT generate a `npx`, `pnpm dlx`, `yarn dlx`, or `bunx` call to the CLI, and SHALL NOT ask the reader to choose a package manager launcher. It SHALL use `node <path>` rather than `./<path>`, since a CI checkout can lose the executable bit and a Windows runner does not honour a shebang.

The recipe SHALL require that the generated job make Node available itself, through the CI system's Node setup step or a Node container image, and SHALL say that the job does not need the project's own language toolchain or a `package.json`. It SHALL name the launcher's version source, `.taskless/taskless.json`, as what decides which CLI version CI runs, so an upgrade reaches CI through the same commit that updates `.taskless/`.

The recipe SHALL have the generated job cache `.taskless/.cache` between runs where the CI system supports a cache, with a key that includes the runner's operating system and a hash of `.taskless/bin/cli.lock.json`, so a restored cache never crosses platforms and an upgrade starts a fresh one.

#### Scenario: GitHub Actions template

- **WHEN** the `ci` recipe is rendered
- **THEN** its GitHub Actions template SHALL contain `node .taskless/bin/cli.mjs check`
- **AND** SHALL contain a Node setup step
- **AND** SHALL NOT contain `%(PACKAGE_MANAGER_DLX)s`

#### Scenario: A repository without package.json

- **WHEN** the `ci` recipe is followed in a repository with no `package.json`
- **THEN** the recipe SHALL direct the job to supply Node itself, and SHALL NOT tell the reader to add a `package.json`

#### Scenario: The launcher cache is restored between runs

- **WHEN** the `ci` recipe is rendered
- **THEN** its GitHub Actions template SHALL cache the path `.taskless/.cache`
- **AND** the cache key SHALL include `runner.os` and `hashFiles('.taskless/bin/cli.lock.json')`
Loading
Loading