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
5 changes: 5 additions & 0 deletions .changeset/init-stale-cli-pins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@taskless/cli": patch
---

`taskless init` names any `package.json` pin of `@taskless/cli` or `@taskless/cli-nightly` that would run an older CLI than the one that just ran (a dependency whose installed build or range is behind, or a script spelling out an older version), with the version to move it to, and offers the bump. Scripts, CI and git hooks run that pin, and a CLI older than the project's `.taskless/` refuses the layout. The install does not edit `package.json`. `init --json` carries the pins as `pinnedCli`, and the `init` (topic v3) and `update` (topic v13) recipes tell an agent to offer the bump.
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
## Why

An upgrade is usually run through a launcher, `npx @taskless/cli@latest init`,
and the launcher leaves the project's own pins alone. A `devDependencies` entry
of `^0.10.2`, or a script spelling out `npx @taskless/cli@0.10.2 check`, is
what CI, a git hook and `pnpm lint` actually run. After the upgrade those run a
CLI older than the `.taskless/` it now finds, and an older CLI refuses a layout
newer than it understands ("Upgrade the CLI to continue"). Nothing about the
upgrade itself fails, so the first sign is a red CI run on the next push, and it
does not read as an upgrade problem when it arrives.

The upgrade trailer already tells the caller what to commit and that `update`
exists. It says nothing about the one other place the upgrade is incomplete.

## What Changes

- `taskless init`, both the batch path and the wizard, reads `package.json` in
the working directory and names every pin of `@taskless/cli` or
`@taskless/cli-nightly` whose ceiling sits below the running CLI: an exact
version, or a `^`/`~` range that cannot reach it, in `dependencies`,
`devDependencies`, `optionalDependencies`, or spelled out in a script. It
offers the bump; it does not make it.
- A dependency is judged by the version installed under `node_modules/` as
well as by its range. `pnpm add -D` writes `^0.11.0` and locks 0.11.0; the
range admits 0.11.2, but CI runs the locked 0.11.0. Exact and installed
versions compare with semver precedence, so an older nightly of the same
base is stale.
- Each pin is named with the version to move it to, on the package that
publishes it: no `@taskless/cli-nightly@<release>` exists, so a nightly pin
under a release CLI moves to `@taskless/cli`, and the reverse.
- When the same run migrated an EXISTING `.taskless/`, the notice states the
breakage as certain rather than likely: a CLI that predates the new schema
refuses the project with `SCAFFOLD_VERSION_MISMATCH`, so CI breaks on the
push carrying the migrated files, and the bump belongs in that same commit.
A fresh install migrates from schema 0 and is not called an upgrade.
- The `init` recipe goes to topic v3: its envelope example and field list
carry `pinnedCli`, "stop when `changed` is false" now also requires no
stale pins, and a step offers the bump.
- `init --json` carries the pins as `pinnedCli`, always present, empty when
nothing is stale.
- The `update` recipe goes to topic v13 with a step telling the agent to offer
the bump as part of the upgrade, without making it silently.
- `compareVersions` moves from `reconcile-marker.ts` to
`util/version-compare.ts`, unchanged, so both callers share it.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `cli-init`: one ADDED requirement. No standing requirement is restated,
renamed or removed. The notice is separate from the upgrade trailer, so the
standing "a no-op re-install prints no upgrade trailer" scenario still holds.

## Impact

Additive output on `init`, plus one envelope field. `patch`: the package is
pre-1.0. A spec this change cannot bound (`latest`, `*`, `>=`, `workspace:`, a
URL) is not reported, so a project that floats its pin sees nothing new.

## Delivery shape

**Single PR.** The spec, detector, wiring, recipe step and tests are one small
reviewable diff. It is the tip, so the change is archived here.
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
## ADDED Requirements

### Requirement: Init names a package.json pin older than the running CLI

After a successful install, `taskless init` SHALL read `package.json` in the working directory and report every pin of `@taskless/cli` or `@taskless/cli-nightly` that would run a CLI older than the running one. A pin is:

- an entry in `dependencies`, `devDependencies`, or `optionalDependencies`; or
- a script in `scripts` that spells out `@taskless/cli@<spec>` or `@taskless/cli-nightly@<spec>`, where the name is not the tail of a longer name and the spec ends at whitespace, a quote, or shell punctuation (`;&|()<>,:`). A pin repeated within one script SHALL be reported once.

A dependency pin SHALL be reported when either holds:

- the version installed at `node_modules/<name>/package.json` is older than the running version, because that, and the lockfile it came from, is what runs; or
- its spec is bounded and cannot reach the running version.

A script pin SHALL be reported when its spec is bounded and cannot reach the running version.

A bounded spec is an exact version (optionally prefixed `=` or `v`, with optional whitespace after the operator, prerelease, and build metadata), or a `^` or `~` range. An exact version, and an installed version, SHALL be compared with semver precedence, so a prerelease sorts before its release and two prereleases of one base compare by their prerelease text, which orders nightlies by build time. A range SHALL be compared by its exclusive ceiling on the numeric core, with caret ranges holding the left-most non-zero part as npm does. A spec the CLI cannot bound (`latest`, `*`, a comparator range, `workspace:`, a URL or git spec) SHALL NOT be reported on its own. An absent or unparseable `package.json`, or an unreadable installed manifest, SHALL produce no report from that source and SHALL NOT fail the install.

The install SHALL NOT modify `package.json`. The report SHALL offer the bump rather than claim it.

On the human path (batch and wizard), when at least one pin is reported, the CLI SHALL print a notice naming, for each pin, its location (the dependency field, or `scripts.<name>`), package, spec, installed version when known, and the package and version to move it to. The target SHALL be the running version on the package that publishes it: `@taskless/cli-nightly` when the running version carries a prerelease, `@taskless/cli` otherwise, with the switch named when the pin is on the other package. The notice SHALL print whether or not the run changed anything. On the batch path it SHALL print after the upgrade trailer, and the onboarding trailer SHALL remain the final line.

When the same run migrated an existing `.taskless/` (the migration's `from` is above `0`), the notice SHALL NOT hedge. It SHALL name the schema versions the run moved between, state that a CLI predating the new schema refuses the project with `SCAFFOLD_VERSION_MISMATCH` so CI running the pins will break on the push carrying the migrated files, and say the bump belongs in the same commit as `.taskless/`. A migration from `0` is how a fresh install creates `.taskless/`; it SHALL NOT be described as an upgrade, and like a run with no migration the notice SHALL describe the failure as likely, not certain.

Under `--json`, the envelope SHALL carry `pinnedCli`: an array of `{ location, name, spec, installed }`, where `installed` is the installed version or `null`, present on every successful run and empty when nothing is stale.

#### Scenario: A stale dependency and script pin are named and left alone

- **WHEN** `taskless init` runs at version `V` in a project whose `package.json` has `devDependencies["@taskless/cli"]` set to a version below `V`, and a script running `npx @taskless/cli@<older>`
- **THEN** stdout SHALL name both pins with their location and spec, and the target `@taskless/cli@V`
- **AND** `package.json` SHALL be byte-identical afterwards
- **AND** the notice SHALL appear after the upgrade trailer, with the onboarding trailer still the final line

#### Scenario: An installed build older than the running CLI is stale even when its range admits the running version

- **WHEN** `package.json` pins `@taskless/cli` at `^0.11.0`, `node_modules/@taskless/cli` is `0.11.0`, and the running CLI is `0.11.2`
- **THEN** the pin SHALL be reported with `installed` set to `0.11.0`

#### Scenario: A pre-1.0 caret range that cannot reach the running version is stale

- **WHEN** `package.json` pins `@taskless/cli` at `^0.10.2`, nothing is installed, and the running CLI is `0.11.2`
- **THEN** the pin SHALL be reported

#### Scenario: An older nightly of the same base is stale

- **WHEN** `package.json` pins `@taskless/cli-nightly` at `0.12.0-20260901000000xaaaaaaa` and the running CLI is `0.12.0-20261005000000xbbbbbbb` or `0.12.0`
- **THEN** the pin SHALL be reported

#### Scenario: A nightly pin moves to the release package when a release is running

- **WHEN** a stale pin names `@taskless/cli-nightly` and the running CLI is a release `V`
- **THEN** the notice SHALL give `@taskless/cli@V` as the target and name the package switch

#### Scenario: A floating or current pin is not reported

- **WHEN** nothing older than the running version is installed, and `package.json` pins `@taskless/cli` at `latest`, `*`, `>=0.10.0`, `workspace:*`, or a range that admits the running version, or a script runs `@taskless/cli@latest`
- **THEN** no pin SHALL be reported

#### Scenario: A migration of an existing scaffold makes the breakage definite

- **WHEN** `taskless init` migrates an existing `.taskless/` from schema version `M` above `0` to `N` in a project with a stale pin
- **THEN** the notice SHALL name schema versions `M` and `N` and `SCAFFOLD_VERSION_MISMATCH`
- **AND** SHALL state that CI running the pins will break, and that the bump belongs in the same commit as `.taskless/`
- **AND** SHALL NOT describe the failure as merely likely

#### Scenario: A fresh install is not called an upgrade

- **WHEN** `taskless init` creates `.taskless/` in a project with a stale pin
- **THEN** the notice SHALL describe the failure as likely
- **AND** SHALL NOT mention `SCAFFOLD_VERSION_MISMATCH` or describe the run as an upgrade

#### Scenario: A stale pin is named on a re-install that changed nothing

- **WHEN** `taskless init` runs against a project that is already current and whose `package.json` holds a stale pin
- **THEN** stdout SHALL NOT contain the upgrade trailer
- **AND** stdout SHALL name the stale pin

#### Scenario: The JSON envelope carries the pins

- **WHEN** `taskless init --json` runs
- **THEN** the envelope SHALL contain `pinnedCli`, an array with one `{ location, name, spec, installed }` entry per stale pin
- **AND** `pinnedCli` SHALL be an empty array when there is no `package.json` or nothing in it is stale
50 changes: 50 additions & 0 deletions openspec/changes/archive/2026-10-05-init-stale-cli-pins/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
## 1. Spec

- [x] 1.1 Add the requirement to `cli-init` as an ADDED block, separate from
the upgrade trailer, so no standing requirement is restated and the
no-op scenario keeps holding.
- [x] 1.2 Dry-run `openspec archive` and compare the scenario count in
`cli-init` before and after.

## 2. Detector

- [x] 2.1 Move `compareVersions` to `util/version-compare.ts`, unchanged.
- [x] 2.2 `install/pinned-cli.ts`: read `package.json`, report bounded pins
whose ceiling is at or below the running version, in the three
dependency fields and in scripts.
- [x] 2.3 Treat an absent or unparseable `package.json` as no pins.

## 3. Wiring

- [x] 3.1 Batch `init`: print the notice after the upgrade trailer, not gated
on the run having changed anything; add `pinnedCli` to the envelope.
- [x] 3.2 Wizard: print the notice after the outro.
- [x] 3.3 When the run migrated, state the breakage as certain: name the
schema version and `SCAFFOLD_VERSION_MISMATCH`, and put the bump in the
same commit as `.taskless/`.

## 4. Recipe

- [x] 4.1 `update` topic v13: a step offering the bump, without making it
silently, before recording the walk.

## 5. Tests

- [x] 5.1 Detector: the spec table in both directions, every dependency
field, the nightly name, scripts, an unreadable `package.json`.
- [x] 5.2 Integration: notice order and content, `package.json` untouched,
the no-op re-install, and the `--json` field.

## 6. Review fixes

- [x] 6.1 Judge a dependency by its installed version as well as its range.
- [x] 6.2 Compare exact and installed versions with semver precedence, so an
older nightly of the same base is stale.
- [x] 6.3 Name each pin's target on the package that publishes it.
- [x] 6.4 Do not call a fresh install (migration from schema 0) an upgrade.
- [x] 6.5 `init` recipe topic v3: `pinnedCli` in the envelope and field list,
the stop rule, and a bump step.
- [x] 6.6 Script regex: left boundary, punctuation-terminated versions, one
report per repeated pin.
- [x] 6.7 Tests: the ordering guard, wizard coverage, fresh install, nightly
ordering, installed versions.
81 changes: 81 additions & 0 deletions openspec/specs/cli-init/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -802,3 +802,84 @@ If the user cancels the wizard at any step (Ctrl-C, Esc, or equivalent clack can
- **WHEN** the user declines the summary confirm
- **THEN** no files SHALL be written
- **AND** the CLI SHALL exit non-zero

### Requirement: Init names a package.json pin older than the running CLI

After a successful install, `taskless init` SHALL read `package.json` in the working directory and report every pin of `@taskless/cli` or `@taskless/cli-nightly` that would run a CLI older than the running one. A pin is:

- an entry in `dependencies`, `devDependencies`, or `optionalDependencies`; or
- a script in `scripts` that spells out `@taskless/cli@<spec>` or `@taskless/cli-nightly@<spec>`, where the name is not the tail of a longer name and the spec ends at whitespace, a quote, or shell punctuation (`;&|()<>,:`). A pin repeated within one script SHALL be reported once.

A dependency pin SHALL be reported when either holds:

- the version installed at `node_modules/<name>/package.json` is older than the running version, because that, and the lockfile it came from, is what runs; or
- its spec is bounded and cannot reach the running version.

A script pin SHALL be reported when its spec is bounded and cannot reach the running version.

A bounded spec is an exact version (optionally prefixed `=` or `v`, with optional whitespace after the operator, prerelease, and build metadata), or a `^` or `~` range. An exact version, and an installed version, SHALL be compared with semver precedence, so a prerelease sorts before its release and two prereleases of one base compare by their prerelease text, which orders nightlies by build time. A range SHALL be compared by its exclusive ceiling on the numeric core, with caret ranges holding the left-most non-zero part as npm does. A spec the CLI cannot bound (`latest`, `*`, a comparator range, `workspace:`, a URL or git spec) SHALL NOT be reported on its own. An absent or unparseable `package.json`, or an unreadable installed manifest, SHALL produce no report from that source and SHALL NOT fail the install.

The install SHALL NOT modify `package.json`. The report SHALL offer the bump rather than claim it.

On the human path (batch and wizard), when at least one pin is reported, the CLI SHALL print a notice naming, for each pin, its location (the dependency field, or `scripts.<name>`), package, spec, installed version when known, and the package and version to move it to. The target SHALL be the running version on the package that publishes it: `@taskless/cli-nightly` when the running version carries a prerelease, `@taskless/cli` otherwise, with the switch named when the pin is on the other package. The notice SHALL print whether or not the run changed anything. On the batch path it SHALL print after the upgrade trailer, and the onboarding trailer SHALL remain the final line.

When the same run migrated an existing `.taskless/` (the migration's `from` is above `0`), the notice SHALL NOT hedge. It SHALL name the schema versions the run moved between, state that a CLI predating the new schema refuses the project with `SCAFFOLD_VERSION_MISMATCH` so CI running the pins will break on the push carrying the migrated files, and say the bump belongs in the same commit as `.taskless/`. A migration from `0` is how a fresh install creates `.taskless/`; it SHALL NOT be described as an upgrade, and like a run with no migration the notice SHALL describe the failure as likely, not certain.

Under `--json`, the envelope SHALL carry `pinnedCli`: an array of `{ location, name, spec, installed }`, where `installed` is the installed version or `null`, present on every successful run and empty when nothing is stale.

#### Scenario: A stale dependency and script pin are named and left alone

- **WHEN** `taskless init` runs at version `V` in a project whose `package.json` has `devDependencies["@taskless/cli"]` set to a version below `V`, and a script running `npx @taskless/cli@<older>`
- **THEN** stdout SHALL name both pins with their location and spec, and the target `@taskless/cli@V`
- **AND** `package.json` SHALL be byte-identical afterwards
- **AND** the notice SHALL appear after the upgrade trailer, with the onboarding trailer still the final line

#### Scenario: An installed build older than the running CLI is stale even when its range admits the running version

- **WHEN** `package.json` pins `@taskless/cli` at `^0.11.0`, `node_modules/@taskless/cli` is `0.11.0`, and the running CLI is `0.11.2`
- **THEN** the pin SHALL be reported with `installed` set to `0.11.0`

#### Scenario: A pre-1.0 caret range that cannot reach the running version is stale

- **WHEN** `package.json` pins `@taskless/cli` at `^0.10.2`, nothing is installed, and the running CLI is `0.11.2`
- **THEN** the pin SHALL be reported

#### Scenario: An older nightly of the same base is stale

- **WHEN** `package.json` pins `@taskless/cli-nightly` at `0.12.0-20260901000000xaaaaaaa` and the running CLI is `0.12.0-20261005000000xbbbbbbb` or `0.12.0`
- **THEN** the pin SHALL be reported

#### Scenario: A nightly pin moves to the release package when a release is running

- **WHEN** a stale pin names `@taskless/cli-nightly` and the running CLI is a release `V`
- **THEN** the notice SHALL give `@taskless/cli@V` as the target and name the package switch

#### Scenario: A floating or current pin is not reported

- **WHEN** nothing older than the running version is installed, and `package.json` pins `@taskless/cli` at `latest`, `*`, `>=0.10.0`, `workspace:*`, or a range that admits the running version, or a script runs `@taskless/cli@latest`
- **THEN** no pin SHALL be reported

#### Scenario: A migration of an existing scaffold makes the breakage definite

- **WHEN** `taskless init` migrates an existing `.taskless/` from schema version `M` above `0` to `N` in a project with a stale pin
- **THEN** the notice SHALL name schema versions `M` and `N` and `SCAFFOLD_VERSION_MISMATCH`
- **AND** SHALL state that CI running the pins will break, and that the bump belongs in the same commit as `.taskless/`
- **AND** SHALL NOT describe the failure as merely likely

#### Scenario: A fresh install is not called an upgrade

- **WHEN** `taskless init` creates `.taskless/` in a project with a stale pin
- **THEN** the notice SHALL describe the failure as likely
- **AND** SHALL NOT mention `SCAFFOLD_VERSION_MISMATCH` or describe the run as an upgrade

#### Scenario: A stale pin is named on a re-install that changed nothing

- **WHEN** `taskless init` runs against a project that is already current and whose `package.json` holds a stale pin
- **THEN** stdout SHALL NOT contain the upgrade trailer
- **AND** stdout SHALL name the stale pin

#### Scenario: The JSON envelope carries the pins

- **WHEN** `taskless init --json` runs
- **THEN** the envelope SHALL contain `pinnedCli`, an array with one `{ location, name, spec, installed }` entry per stale pin
- **AND** `pinnedCli` SHALL be an empty array when there is no `package.json` or nothing in it is stale
Loading
Loading