From ab7efd8800bb7474aadbd5510067be1e3343df7e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 17 Oct 2026 01:59:41 -0700 Subject: [PATCH 1/2] fix(init): keep release and nightly permissions independent A nightly init removed the release's project allow rule and the release removed the nightly's, since every Taskless rule other than the running build's counted as stale. Removal is now scoped to the installing package. The global autoMode entry gets one label per package (Taskless CLI: and Taskless CLI nightly:), so neither build reads the other's entry as stale. --- .changeset/independent-nightly-permissions.md | 5 + .../.openspec.yaml | 2 + .../proposal.md | 33 ++++ .../specs/agent-global-trust/spec.md | 154 ++++++++++++++++++ .../specs/cli-init/spec.md | 52 ++++++ .../specs/skill-taskless/spec.md | 56 +++++++ .../tasks.md | 13 ++ openspec/specs/agent-global-trust/spec.md | 30 +++- openspec/specs/cli-init/spec.md | 24 ++- openspec/specs/skill-taskless/spec.md | 6 +- packages/cli/src/agent/init.md | 5 +- packages/cli/src/install/canonical.ts | 10 +- packages/cli/src/install/global-trust.ts | 101 +++++++++--- packages/cli/src/install/permissions.ts | 46 ++++-- packages/cli/test/apply-install-plan.test.ts | 8 +- packages/cli/test/global-trust.test.ts | 37 ++++- .../cli/test/installed-documentation.test.ts | 26 ++- .../test/nightly/global-trust-nightly.test.ts | 91 +++++++++++ packages/cli/test/permissions.test.ts | 76 ++++++++- packages/cli/test/wizard-integration.test.ts | 11 +- skills/taskless/SKILL.md | 4 +- 21 files changed, 721 insertions(+), 69 deletions(-) create mode 100644 .changeset/independent-nightly-permissions.md create mode 100644 openspec/changes/archive/2026-10-17-independent-nightly-permissions/.openspec.yaml create mode 100644 openspec/changes/archive/2026-10-17-independent-nightly-permissions/proposal.md create mode 100644 openspec/changes/archive/2026-10-17-independent-nightly-permissions/specs/agent-global-trust/spec.md create mode 100644 openspec/changes/archive/2026-10-17-independent-nightly-permissions/specs/cli-init/spec.md create mode 100644 openspec/changes/archive/2026-10-17-independent-nightly-permissions/specs/skill-taskless/spec.md create mode 100644 openspec/changes/archive/2026-10-17-independent-nightly-permissions/tasks.md create mode 100644 packages/cli/test/nightly/global-trust-nightly.test.ts diff --git a/.changeset/independent-nightly-permissions.md b/.changeset/independent-nightly-permissions.md new file mode 100644 index 00000000..b60f47d8 --- /dev/null +++ b/.changeset/independent-nightly-permissions.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +The release and a nightly no longer undo each other's permissions. `init` now replaces stale project allow rules only for the package it is installing (`@taskless/cli` or `@taskless/cli-nightly`), and leaves the other package's rules in place. Each package also gets its own Claude Code `autoMode.allow` entry: the release keeps the `Taskless CLI:` label, and a nightly uses `Taskless CLI nightly:`. Neither build reads the other's entry as stale. One time only, the wizard offers to replace an existing `Taskless CLI:` entry that names both packages with the release-only text. diff --git a/openspec/changes/archive/2026-10-17-independent-nightly-permissions/.openspec.yaml b/openspec/changes/archive/2026-10-17-independent-nightly-permissions/.openspec.yaml new file mode 100644 index 00000000..b699b88b --- /dev/null +++ b/openspec/changes/archive/2026-10-17-independent-nightly-permissions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-17 diff --git a/openspec/changes/archive/2026-10-17-independent-nightly-permissions/proposal.md b/openspec/changes/archive/2026-10-17-independent-nightly-permissions/proposal.md new file mode 100644 index 00000000..808fea3b --- /dev/null +++ b/openspec/changes/archive/2026-10-17-independent-nightly-permissions/proposal.md @@ -0,0 +1,33 @@ +## Why + +Testing a nightly next to the release is awkward because each one undoes the other's permissions. Installing `@taskless/cli-nightly` in a project removes the `Bash(npx @taskless/cli@ *)` rule the release wrote, since every Taskless rule that is not the installed invocation's is treated as stale. Running the release again removes the nightly's rule. A project used with both builds never holds a rule for the one about to run. + +Claude Code's user-level `autoMode.allow` entry has the same flaw, latent until the wording next changes. Both builds find the entry by one label, `Taskless CLI:`, and treat any labelled text other than their own as stale. The first nightly that rewords the entry will offer to replace the release's text, and the release will offer to put it back, on every run of either. + +## What Changes + +- **A project permission file holds one Taskless rule set per package.** A Taskless rule belongs to the package its command names. `init` removes stale rules only for the package it is installing (`@taskless/cli` or `@taskless/cli-nightly`), and never touches the other package's rules. The manifest record keeps the other package's recorded rules, so neither build loses track of a file it created. A build whose invocation names neither package (`self`, a repository path) removes no npx Taskless rules. +- **Each package has its own global trust entry.** The release entry keeps the label `Taskless CLI:` and names only `@taskless/cli`. The nightly entry has the label `Taskless CLI nightly:` and names only `@taskless/cli-nightly`. A build reads, offers, replaces, and warns about only its own entry, and never reads the other label as stale. **BREAKING** for the current spec, which requires one text that names both packages in every build. Once, on the first release that carries this, an existing `Taskless CLI:` entry naming both packages reads as stale, and the wizard offers to replace it. +- **The installed skill carries the installing build's entry.** The skill source spells the label and the entry as placeholders, which install renders for the build, as it already does for the invocation. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli-init`: what counts as a Taskless rule to remove is scoped to the installing package, and the manifest record keeps the other package's rules. +- `agent-global-trust`: one entry and label per package instead of one shared entry. +- `skill-taskless`: the classifier fallback names this build's label and entry rather than a fixed one. + +## Impact + +- `packages/cli/src/install/permissions.ts`: package-scoped removal and record carry-over in `syncPermissions()`. +- `packages/cli/src/install/global-trust.ts`: per-build label and entry, and a renderer for the skill placeholders. +- `packages/cli/src/install/canonical.ts`: renders the trust placeholders into the canonical skill. +- `skills/taskless/SKILL.md`: the label and entry become placeholders. +- `packages/cli/src/agent/init.md`: the stale state names this build's label. +- Tests: `permissions.test.ts` (or the existing permission suite), `global-trust.test.ts`, `installed-documentation.test.ts`, and a nightly-project test for the nightly entry. +- Delivery shape: **single PR**. Both halves are the same idea, applied to two files, and fit in one review. diff --git a/openspec/changes/archive/2026-10-17-independent-nightly-permissions/specs/agent-global-trust/spec.md b/openspec/changes/archive/2026-10-17-independent-nightly-permissions/specs/agent-global-trust/spec.md new file mode 100644 index 00000000..9ebb8f00 --- /dev/null +++ b/openspec/changes/archive/2026-10-17-independent-nightly-permissions/specs/agent-global-trust/spec.md @@ -0,0 +1,154 @@ +## MODIFIED Requirements + +### Requirement: The global entry names no version and requires one + +Each package SHALL have its own entry: the release's begins with the label `Taskless CLI:` and names only `@taskless/cli`; the nightly's begins with the label `Taskless CLI nightly:` and names only `@taskless/cli-nightly`. Neither label SHALL be a prefix of the other's entry. The released build SHALL use the release's entry, and every other build (nightly, `self`) the nightly's. Each entry SHALL state that running its package through `npx` or `pnpm dlx` is allowed only when the call names an exact version, and state that dist-tags such as `@latest` or `@nightly`, version ranges, and unversioned calls are not covered. It SHALL NOT name a version, so an upgrade never makes it stale. + +Each entry SHALL also state that the call stays covered when the same command line sets a variable, changes or creates a directory, or redirects the call's output to a file, and that every other command on that line is judged on its own. It SHALL NOT extend coverage to any command other than the pinned Taskless call. + +#### Scenario: The entry carries no version + +- **WHEN** a package's entry is written by any version of that package +- **THEN** its text SHALL be identical, and SHALL contain no version number other than the fixed example + +#### Scenario: Each entry names only its own package + +- **WHEN** the release's and the nightly's entries are read +- **THEN** the release's SHALL begin `Taskless CLI:` and SHALL NOT name `@taskless/cli-nightly` +- **AND** the nightly's SHALL begin `Taskless CLI nightly:` and name `@taskless/cli-nightly` + +#### Scenario: The entry states the pin requirement + +- **WHEN** the entry is read +- **THEN** it SHALL say a call is covered only when it names an exact version +- **AND** it SHALL say `@latest` is not covered + +#### Scenario: The entry covers the call inside shell plumbing + +- **WHEN** the entry is read +- **THEN** it SHALL say the call stays covered alongside a variable assignment, a directory change or creation, or a redirect of its output to a file +- **AND** it SHALL say every other command on the line is judged on its own + +### Requirement: The global entry is detected by its label + +`init` SHALL find the entry by this build's label: a string in the harness's list beginning with it is this build's entry, whatever follows. A string beginning with the other package's label SHALL never be read, edited, or removed, so a release and a nightly used on one machine never replace each other's entry. An entry whose whole text equals the current entry SHALL be `present` and SHALL never be rewritten. An entry that carries the label with any other text SHALL be `stale`. A string without this build's label SHALL never be read as this build's entry, edited, or removed. + +A stale entry SHALL be replaced only in place, by the current entry, and only where the wizard writes it. When the list holds more than one string carrying the label, the first SHALL be replaced and the rest left as they are. + +#### Scenario: A current entry is left alone + +- **WHEN** `autoMode.allow` holds the current entry text +- **THEN** `init` SHALL report the entry as present +- **AND** SHALL NOT edit the file + +#### Scenario: A reworded entry is left alone + +- **WHEN** `autoMode.allow` holds `"Taskless CLI: allowed with a pinned version"` +- **THEN** `init` SHALL report the entry as stale +- **AND** SHALL NOT edit the file, leaving the replacement to the wizard + +#### Scenario: An unlabelled entry is never Taskless's + +- **WHEN** `autoMode.allow` holds `"Internal Registry: ..."` and no `Taskless CLI:` entry +- **THEN** `init` SHALL report the entry as absent +- **AND** SHALL NOT treat `"Internal Registry: ..."` as stale + +#### Scenario: The other package's entry is left alone + +- **WHEN** the released build runs and `autoMode.allow` is `["$defaults", ]` +- **THEN** `init` SHALL report the release's entry as absent, not stale +- **AND** writing it SHALL give `["$defaults", , ]` + +### Requirement: The global entry is offered only in the interactive wizard + +When the entry is absent or stale, the wizard SHALL show the proposed edit in its summary step, in the same group as the project permission edits and after them, as one line: `ALLOW (global):` for an absent entry, `REPLACE (global):` for a stale one. The line SHALL name this build's package, say a call is covered only at an exact version, and name the file and key. Project and global edits SHALL be covered by the one confirmation the summary step asks, defaulting to yes; there SHALL be no separate question for the global entry. Declining that confirmation SHALL cancel the install and write nothing, inside or outside the project. + +`--no-interactive` and `--json` SHALL NEVER write a file outside the project. + +When written, the edit SHALL add the entry to the list, or for a stale entry replace that one string at its position, keeping every other key and entry in the file. When the file or the `allow` list does not exist, the written list SHALL be `["$defaults", ]`, so the harness's shipped exceptions stay in force. When the list exists without `"$defaults"`, `init` SHALL NOT add it, since the user chose to replace the defaults. When the file does not parse, `init` SHALL leave it unchanged and report it as skipped. + +Nothing about the entry SHALL be recorded in the project manifest, and a declined offer SHALL NOT be remembered anywhere: every wizard run that finds the entry absent or stale SHALL offer it again. + +#### Scenario: The wizard offers the entry + +- **WHEN** the wizard installs for `.claude/` and `~/.claude/settings.json` has no entry under this build's label +- **THEN** the summary SHALL show an `ALLOW (global):` line naming `~/.claude/settings.json` and `autoMode.allow` +- **AND** the one confirmation SHALL be `Make these changes to your settings?` when nothing else is being removed + +#### Scenario: The wizard offers to replace a stale entry + +- **WHEN** the wizard installs for `.claude/` and `autoMode.allow` is `["$defaults", "Taskless CLI: older text", "Internal Registry: ..."]` +- **THEN** the summary SHALL show a `REPLACE (global):` line naming `~/.claude/settings.json` and `autoMode.allow` +- **AND** on confirmation the list SHALL be `["$defaults", , "Internal Registry: ..."]` + +#### Scenario: Declining writes nothing anywhere + +- **WHEN** the user declines the summary's confirmation +- **THEN** `~/.claude/settings.json` SHALL be byte-identical afterwards +- **AND** no project permission file SHALL be written + +#### Scenario: The global entry alone is still asked + +- **WHEN** the project's permission files already hold the installed rule and the entry is absent or stale +- **THEN** the summary SHALL show only the global line and ask the same confirmation + +#### Scenario: A declined offer is asked again + +- **WHEN** the user declined the entry on an earlier wizard run and it is still absent or stale +- **THEN** the next wizard run SHALL show and ask for it again + +#### Scenario: A new list keeps the defaults + +- **WHEN** the entry is written to a `~/.claude/settings.json` with no `autoMode` key +- **THEN** `autoMode.allow` SHALL be `["$defaults", ]` +- **AND** every other key in the file SHALL be unchanged + +#### Scenario: An existing list is appended to + +- **WHEN** `autoMode.allow` is `["$defaults", "Internal Registry: ..."]` +- **THEN** the written list SHALL be `["$defaults", "Internal Registry: ...", ]` + +#### Scenario: Non-interactive never writes it + +- **WHEN** `init --no-interactive` runs for `.claude/` and the entry is absent or stale +- **THEN** `~/.claude/settings.json` SHALL be byte-identical afterwards + +#### Scenario: The manifest does not record it + +- **WHEN** the wizard writes the entry +- **THEN** `.taskless/taskless.json` SHALL contain no reference to `~/.claude/settings.json` + +### Requirement: A missing global entry is warned about + +When a harness in the table is installed for and its entry is absent or stale after `init` finishes, `init` SHALL print a warning, in both modes, naming the harness, the file, the top-level key, and the exact entry to add, saying that without it the harness's classifier may refuse Taskless commands that the project rule allows, and naming the CLI invocation, run with no arguments in a terminal, as the way to be offered it, since `init` itself never asks. For a stale entry the warning SHALL say the existing entry under this build's label is out of date and is to be replaced by the one shown. Under `--json` the warning SHALL go to stderr, and the envelope SHALL report, per harness in the table, the file and whether the entry is present, absent, stale, was written, or was skipped with a reason. + +#### Scenario: Non-interactive warns + +- **WHEN** `init --no-interactive` runs for `.claude/` and the entry is absent +- **THEN** the output SHALL include a warning naming `~/.claude/settings.json`, `autoMode.allow`, and the entry text +- **AND** the warning SHALL name the installer invocation, run with no arguments in a terminal, as the way to be offered it + +#### Scenario: Non-interactive warns about a stale entry + +- **WHEN** `init --no-interactive` runs for `.claude/` and the entry is stale +- **THEN** the output SHALL include a warning saying the existing entry under this build's label is out of date, with the current entry text + +#### Scenario: A skipped file warns after a wizard install + +- **WHEN** the wizard completes an install for `.claude/` and `~/.claude/settings.json` does not parse +- **THEN** the wizard SHALL print the same warning, with the reason the file was left alone + +#### Scenario: A present entry is silent + +- **WHEN** the entry is present +- **THEN** `init` SHALL print no global trust warning + +#### Scenario: JSON reports the state + +- **WHEN** `init --json --no-interactive` runs for `.claude/` and the entry is absent +- **THEN** the envelope SHALL report `~/.claude/settings.json` with the entry absent + +#### Scenario: JSON reports a stale entry + +- **WHEN** `init --json --no-interactive` runs for `.claude/` and the entry is stale +- **THEN** the envelope SHALL report `~/.claude/settings.json` with state `stale` diff --git a/openspec/changes/archive/2026-10-17-independent-nightly-permissions/specs/cli-init/spec.md b/openspec/changes/archive/2026-10-17-independent-nightly-permissions/specs/cli-init/spec.md new file mode 100644 index 00000000..2f463692 --- /dev/null +++ b/openspec/changes/archive/2026-10-17-independent-nightly-permissions/specs/cli-init/spec.md @@ -0,0 +1,52 @@ +## MODIFIED Requirements + +### Requirement: Each permission file holds exactly one Taskless rule + +Each harness permission file `init` manages SHALL hold exactly one Taskless rule set per package afterwards: for the installed invocation's package, the rules for the installed invocation, from the table in the requirement that init allows the installed CLI. A Taskless rule is an allow entry, in that harness's format, whose command is an invocation of `@taskless/cli` or `@taskless/cli-nightly` through `npx` or `pnpm dlx`, at any version, with or without a version, and it belongs to the package its command names. For Claude Code this includes both the `Bash( *)` form and the `Bash(:*)` form. On every `init` the installer SHALL remove each Taskless rule of the installed invocation's package that is not one of the installed invocation's rules, whether or not the manifest recorded it, and write the current ones. It SHALL NOT remove a Taskless rule of the other package, so the release and a nightly installed in one project keep each other's rules. An invocation that names neither package, such as a `self` build's path, SHALL remove no Taskless rule. It SHALL NOT remove or edit a deny entry, or any entry that is not a Taskless rule. + +The manifest SHALL record, under `install.permissions`, each file Taskless wrote a rule to, the exact rule entries it added, and whether Taskless created the file. The record decides only whether a file may be deleted: a file Taskless created SHALL be deleted when removing its entries leaves it holding nothing else; a file that existed before SHALL never be deleted. A recorded file for a harness no longer selected SHALL have the installed package's Taskless rules removed. The record SHALL keep the other package's recorded rules, so a file Taskless created is deleted once the last package's rules leave it. + +#### Scenario: An upgrade swaps the version + +- **WHEN** the manifest records `Bash(npx @taskless/cli@0.13.0 *)` in `.claude/settings.json` and `init` runs at `0.13.1` +- **THEN** the file SHALL contain `Bash(npx @taskless/cli@0.13.1 *)` and not the `0.13.0` rule +- **AND** the manifest SHALL record only the `0.13.1` rule + +#### Scenario: An unrecorded Taskless rule is replaced + +- **WHEN** `.claude/settings.json` also holds `Bash(npx @taskless/cli@0.12.0 *)`, which the manifest does not record +- **THEN** an upgrade SHALL remove that entry +- **AND** the file SHALL hold exactly one Taskless rule, for the installed invocation + +#### Scenario: The colon form the skill proposed is replaced + +- **WHEN** `.claude/settings.json` holds `Bash(npx @taskless/cli-nightly@0.13.0-20261015220659xbf27a23:*)` and a nightly `init` runs +- **THEN** `init` SHALL remove it and write the installed invocation's rule + +#### Scenario: Other entries are untouched + +- **WHEN** `.claude/settings.json` holds `Bash(npx prettier *)` and a deny entry `Bash(npx @taskless/cli@0.11.0 *)` +- **THEN** `init` SHALL leave both entries in place + +#### Scenario: A pre-existing file is never deleted + +- **WHEN** removing Taskless rules from a `.claude/settings.json` that Taskless did not create leaves its `permissions.allow` empty +- **THEN** the file SHALL remain + +#### Scenario: The release and a nightly keep each other's rules + +- **WHEN** `init` at `npx @taskless/cli@0.13.0` has written its rule, and a nightly `init` then runs +- **THEN** `.claude/settings.json` SHALL hold the release's rule and the nightly's rule +- **AND** running either `init` again SHALL report no edit + +#### Scenario: A nightly upgrade swaps only the nightly's rule + +- **WHEN** `.claude/settings.json` holds the release's rule and an older nightly's rule, and a newer nightly `init` runs +- **THEN** the older nightly rule SHALL be removed and the newer one written +- **AND** the release's rule SHALL remain + +#### Scenario: A created file outlives one package's removal + +- **WHEN** Taskless created `.claude/settings.json` holding the release's and a nightly's rules, and the release's `init` runs with `.claude/` no longer selected +- **THEN** the file SHALL remain, holding the nightly's rule +- **AND** once the nightly's `init` runs the same way, the file SHALL be deleted diff --git a/openspec/changes/archive/2026-10-17-independent-nightly-permissions/specs/skill-taskless/spec.md b/openspec/changes/archive/2026-10-17-independent-nightly-permissions/specs/skill-taskless/spec.md new file mode 100644 index 00000000..9ed37e52 --- /dev/null +++ b/openspec/changes/archive/2026-10-17-independent-nightly-permissions/specs/skill-taskless/spec.md @@ -0,0 +1,56 @@ +## MODIFIED Requirements + +### Requirement: Skill preflights the CLI before the first Taskless command + +The skill body SHALL instruct the agent to run `%(TASKLESS_CLI)s agent ok` once per session, before any other Taskless command, and to proceed only once it prints its confirmation line. Once it does, the agent SHALL go straight to the user's request, without surveying the install or offering a menu of topics. On an install whose harness permission rule is in place the preflight passes without a prompt; the guidance below is the fallback for when it does not. + +When the preflight, or any later Taskless command, is blocked by the harness or the user, the body SHALL instruct the agent to: + +1. Stop before doing any further Taskless work, and not retry the command or attempt to work around the block. The one exception is a command that was refused while combined with other commands on its line: the agent SHALL rerun it once, on its own, before treating it as blocked. +2. Send one message that names the exact command, and says it downloads and runs a package from npm. +3. When the refusal came from the agent's own safety classifier rather than a missing permission (in Claude Code, a denial by the auto mode classifier), say that an allow rule cannot clear it. When the user's settings (in Claude Code, top-level `autoMode.allow` in `~/.claude/settings.json`) already hold an entry that matches this build's global trust entry, the agent SHALL say the entry is present and SHALL NOT tell the user to add it. When the entry is absent, or carries the label with different text, the agent SHALL give the user the global trust entry, inline, with the file and key to add it under, after `"$defaults"`, replacing an older entry under this build's label. The agent SHALL NOT write that entry itself. In every case it SHALL also offer running the CLI with no arguments in a terminal, which offers to add or refresh the entry, per-command approval in chat, and leaving the classifier's mode, as the alternatives. +4. Otherwise, offer the user three ways forward: the agent adds an allow rule for exactly the invocation the skill names to the agent's permission settings, and if the harness blocks that edit too, gives the user the change to make themselves; the user approves it through their agent's permissions command (in Claude Code, `/permissions` with the rule `Bash(:*)`); or the user approves every Taskless command manually, in chat, as the agent runs it. +5. Never propose an allow rule that widens the invocation, such as a wildcard over the package name or its version. Where the harness's allowlist cannot scope to the one invocation, for example one that only allows a whole tool, offer per-command approval in chat instead of a rule. +6. Not ask in that message what the user wants Taskless to do. Their request stands, and the agent carries it out once the command is unblocked. + +The global trust entry and these instructions SHALL be written in the skill body itself, the entry and its label as placeholders that install renders to the installing build's entry and label, not fetched from an `agent ` recipe, because a recipe is fetched through the same command that is blocked. + +#### Scenario: Skill body runs the preflight first + +- **WHEN** the skill body is read from source +- **THEN** it SHALL instruct the agent to run `%(TASKLESS_CLI)s agent ok` before any other Taskless command in the session + +#### Scenario: Skill body handles a blocked preflight + +- **WHEN** the skill body is read from source +- **THEN** it SHALL instruct the agent, on a blocked preflight, to stop and name the exact command to the user in one message +- **AND** SHALL offer an allow rule for the exact invocation added by the agent, with the user making the change when the harness blocks the agent's edit +- **AND** SHALL offer the agent's permissions command, and per-command approval in chat +- **AND** SHALL NOT offer running the command with `!`, which unblocks only the preflight +- **AND** SHALL forbid an allow rule that wildcards the package name or version +- **AND** SHALL offer only per-command approval where the harness cannot allow the one invocation without allowing a whole tool +- **AND** SHALL forbid asking what the user wants Taskless to do in that message + +#### Scenario: Skill body handles a classifier refusal + +- **WHEN** the skill body is read from source +- **THEN** it SHALL instruct the agent, when a Taskless command is refused by the agent's safety classifier, to stop without retrying +- **AND** SHALL contain this build's global trust entry inline once installed: the release's, beginning `Taskless CLI:`, from the released build, and the nightly's, beginning `Taskless CLI nightly:`, from a nightly +- **AND** SHALL name `~/.claude/settings.json` and `autoMode.allow` for Claude Code +- **AND** SHALL NOT offer a project allow rule for that refusal +- **AND** SHALL forbid the agent from writing the entry itself + +#### Scenario: A present entry is not asked for again + +- **WHEN** the skill body is read from source +- **THEN** it SHALL instruct the agent, when the classifier refuses a Taskless command and the current entry is already in the user's settings, to say so and not tell the user to add it + +#### Scenario: A combined command is rerun on its own once + +- **WHEN** the skill body is read from source +- **THEN** it SHALL instruct the agent, when a refused Taskless command was combined with other commands on its line, to rerun it once on its own before treating it as blocked + +#### Scenario: The fallback does not depend on a recipe + +- **WHEN** the skill body is read from source +- **THEN** its blocked-command guidance SHALL NOT instruct the agent to run an `agent ` command to learn how to unblock diff --git a/openspec/changes/archive/2026-10-17-independent-nightly-permissions/tasks.md b/openspec/changes/archive/2026-10-17-independent-nightly-permissions/tasks.md new file mode 100644 index 00000000..bbdd0d56 --- /dev/null +++ b/openspec/changes/archive/2026-10-17-independent-nightly-permissions/tasks.md @@ -0,0 +1,13 @@ +## 1. Project permission rules + +- [x] 1.1 Scope removal in `syncPermissions()` to the installed invocation's package, keep the other package's recorded rules, and remove no package rules for an invocation that names neither; verify `permissions.test.ts` covers side-by-side installs, a nightly upgrade, record carry-over, and a `self` invocation + +## 2. Global trust entry + +- [x] 2.1 Give each package its own entry and label in `global-trust.ts`, select by build target, and detect, replace, describe, and warn by this build's label; verify `global-trust.test.ts` and a `nightly`-project test cover both entries leaving each other alone +- [x] 2.2 Spell the entry and label as placeholders in `skills/taskless/SKILL.md`, rendered by `writeCanonicalSkill()`; reword the `stale` state in `src/agent/init.md`; verify `installed-documentation.test.ts` and the nightly skill test + +## 3. Release + +- [x] 3.1 Add a `patch` changeset; run `pnpm typecheck`, `pnpm lint`, and the full test suite +- [x] 3.2 Run the pre-archive scenario check from CLAUDE.md, then archive the change diff --git a/openspec/specs/agent-global-trust/spec.md b/openspec/specs/agent-global-trust/spec.md index 4a2217f0..59fbee52 100644 --- a/openspec/specs/agent-global-trust/spec.md +++ b/openspec/specs/agent-global-trust/spec.md @@ -28,14 +28,20 @@ A harness SHALL be considered only when `init` installs the skill for it, by the ### Requirement: The global entry names no version and requires one -The entry SHALL begin with the label `Taskless CLI:`. It SHALL name `@taskless/cli` and `@taskless/cli-nightly`, state that running either through `npx` or `pnpm dlx` is allowed only when the call names an exact version, and state that dist-tags such as `@latest` or `@nightly`, version ranges, and unversioned calls are not covered. It SHALL NOT name a version, so an upgrade never makes it stale. +Each package SHALL have its own entry: the release's begins with the label `Taskless CLI:` and names only `@taskless/cli`; the nightly's begins with the label `Taskless CLI nightly:` and names only `@taskless/cli-nightly`. Neither label SHALL be a prefix of the other's entry. The released build SHALL use the release's entry, and every other build (nightly, `self`) the nightly's. Each entry SHALL state that running its package through `npx` or `pnpm dlx` is allowed only when the call names an exact version, and state that dist-tags such as `@latest` or `@nightly`, version ranges, and unversioned calls are not covered. It SHALL NOT name a version, so an upgrade never makes it stale. -The entry SHALL also state that the call stays covered when the same command line sets a variable, changes or creates a directory, or redirects the call's output to a file, and that every other command on that line is judged on its own. It SHALL NOT extend coverage to any command other than the pinned Taskless call. +Each entry SHALL also state that the call stays covered when the same command line sets a variable, changes or creates a directory, or redirects the call's output to a file, and that every other command on that line is judged on its own. It SHALL NOT extend coverage to any command other than the pinned Taskless call. #### Scenario: The entry carries no version -- **WHEN** the entry is written by any version of the CLI -- **THEN** its text SHALL be identical, and SHALL contain no version number +- **WHEN** a package's entry is written by any version of that package +- **THEN** its text SHALL be identical, and SHALL contain no version number other than the fixed example + +#### Scenario: Each entry names only its own package + +- **WHEN** the release's and the nightly's entries are read +- **THEN** the release's SHALL begin `Taskless CLI:` and SHALL NOT name `@taskless/cli-nightly` +- **AND** the nightly's SHALL begin `Taskless CLI nightly:` and name `@taskless/cli-nightly` #### Scenario: The entry states the pin requirement @@ -51,7 +57,7 @@ The entry SHALL also state that the call stays covered when the same command lin ### Requirement: The global entry is detected by its label -`init` SHALL find the entry by the label `Taskless CLI:`: a string in the harness's list beginning with it is Taskless's entry, whatever follows. An entry whose whole text equals the current entry SHALL be `present` and SHALL never be rewritten. An entry that carries the label with any other text SHALL be `stale`. A string without the label SHALL never be read as Taskless's, edited, or removed. +`init` SHALL find the entry by this build's label: a string in the harness's list beginning with it is this build's entry, whatever follows. A string beginning with the other package's label SHALL never be read, edited, or removed, so a release and a nightly used on one machine never replace each other's entry. An entry whose whole text equals the current entry SHALL be `present` and SHALL never be rewritten. An entry that carries the label with any other text SHALL be `stale`. A string without this build's label SHALL never be read as this build's entry, edited, or removed. A stale entry SHALL be replaced only in place, by the current entry, and only where the wizard writes it. When the list holds more than one string carrying the label, the first SHALL be replaced and the rest left as they are. @@ -73,9 +79,15 @@ A stale entry SHALL be replaced only in place, by the current entry, and only wh - **THEN** `init` SHALL report the entry as absent - **AND** SHALL NOT treat `"Internal Registry: ..."` as stale +#### Scenario: The other package's entry is left alone + +- **WHEN** the released build runs and `autoMode.allow` is `["$defaults", ]` +- **THEN** `init` SHALL report the release's entry as absent, not stale +- **AND** writing it SHALL give `["$defaults", , ]` + ### Requirement: The global entry is offered only in the interactive wizard -When the entry is absent or stale, the wizard SHALL show the proposed edit in its summary step, in the same group as the project permission edits and after them, as one line: `ALLOW (global):` for an absent entry, `REPLACE (global):` for a stale one. The line SHALL name the packages, say a call is covered only at an exact version, and name the file and key. Project and global edits SHALL be covered by the one confirmation the summary step asks, defaulting to yes; there SHALL be no separate question for the global entry. Declining that confirmation SHALL cancel the install and write nothing, inside or outside the project. +When the entry is absent or stale, the wizard SHALL show the proposed edit in its summary step, in the same group as the project permission edits and after them, as one line: `ALLOW (global):` for an absent entry, `REPLACE (global):` for a stale one. The line SHALL name this build's package, say a call is covered only at an exact version, and name the file and key. Project and global edits SHALL be covered by the one confirmation the summary step asks, defaulting to yes; there SHALL be no separate question for the global entry. Declining that confirmation SHALL cancel the install and write nothing, inside or outside the project. `--no-interactive` and `--json` SHALL NEVER write a file outside the project. @@ -85,7 +97,7 @@ Nothing about the entry SHALL be recorded in the project manifest, and a decline #### Scenario: The wizard offers the entry -- **WHEN** the wizard installs for `.claude/` and `~/.claude/settings.json` has no `Taskless CLI:` entry +- **WHEN** the wizard installs for `.claude/` and `~/.claude/settings.json` has no entry under this build's label - **THEN** the summary SHALL show an `ALLOW (global):` line naming `~/.claude/settings.json` and `autoMode.allow` - **AND** the one confirmation SHALL be `Make these changes to your settings?` when nothing else is being removed @@ -134,7 +146,7 @@ Nothing about the entry SHALL be recorded in the project manifest, and a decline ### Requirement: A missing global entry is warned about -When a harness in the table is installed for and its entry is absent or stale after `init` finishes, `init` SHALL print a warning, in both modes, naming the harness, the file, the top-level key, and the exact entry to add, saying that without it the harness's classifier may refuse Taskless commands that the project rule allows, and naming the CLI invocation, run with no arguments in a terminal, as the way to be offered it, since `init` itself never asks. For a stale entry the warning SHALL say the existing `Taskless CLI:` entry is out of date and is to be replaced by the one shown. Under `--json` the warning SHALL go to stderr, and the envelope SHALL report, per harness in the table, the file and whether the entry is present, absent, stale, was written, or was skipped with a reason. +When a harness in the table is installed for and its entry is absent or stale after `init` finishes, `init` SHALL print a warning, in both modes, naming the harness, the file, the top-level key, and the exact entry to add, saying that without it the harness's classifier may refuse Taskless commands that the project rule allows, and naming the CLI invocation, run with no arguments in a terminal, as the way to be offered it, since `init` itself never asks. For a stale entry the warning SHALL say the existing entry under this build's label is out of date and is to be replaced by the one shown. Under `--json` the warning SHALL go to stderr, and the envelope SHALL report, per harness in the table, the file and whether the entry is present, absent, stale, was written, or was skipped with a reason. #### Scenario: Non-interactive warns @@ -145,7 +157,7 @@ When a harness in the table is installed for and its entry is absent or stale af #### Scenario: Non-interactive warns about a stale entry - **WHEN** `init --no-interactive` runs for `.claude/` and the entry is stale -- **THEN** the output SHALL include a warning saying the existing `Taskless CLI:` entry is out of date, with the current entry text +- **THEN** the output SHALL include a warning saying the existing entry under this build's label is out of date, with the current entry text #### Scenario: A skipped file warns after a wizard install diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 202f365b..8e5cbd3d 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -1055,9 +1055,9 @@ Gemini CLI SHALL be detected when a `.gemini/` directory or a `GEMINI.md` file e ### Requirement: Each permission file holds exactly one Taskless rule -Each harness permission file `init` manages SHALL hold exactly one Taskless rule set afterwards: the rules for the installed invocation, from the table in the requirement that init allows the installed CLI. A Taskless rule is an allow entry, in that harness's format, whose command is an invocation of `@taskless/cli` or `@taskless/cli-nightly` through `npx` or `pnpm dlx`, at any version, with or without a version. For Claude Code this includes both the `Bash( *)` form and the `Bash(:*)` form. On every `init` the installer SHALL remove each Taskless rule that is not one of the installed invocation's rules, whether or not the manifest recorded it, and write the current ones. It SHALL NOT remove or edit a deny entry, or any entry that is not a Taskless rule. +Each harness permission file `init` manages SHALL hold exactly one Taskless rule set per package afterwards: for the installed invocation's package, the rules for the installed invocation, from the table in the requirement that init allows the installed CLI. A Taskless rule is an allow entry, in that harness's format, whose command is an invocation of `@taskless/cli` or `@taskless/cli-nightly` through `npx` or `pnpm dlx`, at any version, with or without a version, and it belongs to the package its command names. For Claude Code this includes both the `Bash( *)` form and the `Bash(:*)` form. On every `init` the installer SHALL remove each Taskless rule of the installed invocation's package that is not one of the installed invocation's rules, whether or not the manifest recorded it, and write the current ones. It SHALL NOT remove a Taskless rule of the other package, so the release and a nightly installed in one project keep each other's rules. An invocation that names neither package, such as a `self` build's path, SHALL remove no Taskless rule. It SHALL NOT remove or edit a deny entry, or any entry that is not a Taskless rule. -The manifest SHALL record, under `install.permissions`, each file Taskless wrote a rule to, the exact rule entries it added, and whether Taskless created the file. The record decides only whether a file may be deleted: a file Taskless created SHALL be deleted when removing its entries leaves it holding nothing else; a file that existed before SHALL never be deleted. A recorded file for a harness no longer selected SHALL have its Taskless rules removed. +The manifest SHALL record, under `install.permissions`, each file Taskless wrote a rule to, the exact rule entries it added, and whether Taskless created the file. The record decides only whether a file may be deleted: a file Taskless created SHALL be deleted when removing its entries leaves it holding nothing else; a file that existed before SHALL never be deleted. A recorded file for a harness no longer selected SHALL have the installed package's Taskless rules removed. The record SHALL keep the other package's recorded rules, so a file Taskless created is deleted once the last package's rules leave it. #### Scenario: An upgrade swaps the version @@ -1073,7 +1073,7 @@ The manifest SHALL record, under `install.permissions`, each file Taskless wrote #### Scenario: The colon form the skill proposed is replaced -- **WHEN** `.claude/settings.json` holds `Bash(npx @taskless/cli-nightly@0.13.0-20261015220659xbf27a23:*)` +- **WHEN** `.claude/settings.json` holds `Bash(npx @taskless/cli-nightly@0.13.0-20261015220659xbf27a23:*)` and a nightly `init` runs - **THEN** `init` SHALL remove it and write the installed invocation's rule #### Scenario: Other entries are untouched @@ -1085,3 +1085,21 @@ The manifest SHALL record, under `install.permissions`, each file Taskless wrote - **WHEN** removing Taskless rules from a `.claude/settings.json` that Taskless did not create leaves its `permissions.allow` empty - **THEN** the file SHALL remain + +#### Scenario: The release and a nightly keep each other's rules + +- **WHEN** `init` at `npx @taskless/cli@0.13.0` has written its rule, and a nightly `init` then runs +- **THEN** `.claude/settings.json` SHALL hold the release's rule and the nightly's rule +- **AND** running either `init` again SHALL report no edit + +#### Scenario: A nightly upgrade swaps only the nightly's rule + +- **WHEN** `.claude/settings.json` holds the release's rule and an older nightly's rule, and a newer nightly `init` runs +- **THEN** the older nightly rule SHALL be removed and the newer one written +- **AND** the release's rule SHALL remain + +#### Scenario: A created file outlives one package's removal + +- **WHEN** Taskless created `.claude/settings.json` holding the release's and a nightly's rules, and the release's `init` runs with `.claude/` no longer selected +- **THEN** the file SHALL remain, holding the nightly's rule +- **AND** once the nightly's `init` runs the same way, the file SHALL be deleted diff --git a/openspec/specs/skill-taskless/spec.md b/openspec/specs/skill-taskless/spec.md index 7e1c442a..900e8c98 100644 --- a/openspec/specs/skill-taskless/spec.md +++ b/openspec/specs/skill-taskless/spec.md @@ -166,12 +166,12 @@ When the preflight, or any later Taskless command, is blocked by the harness or 1. Stop before doing any further Taskless work, and not retry the command or attempt to work around the block. The one exception is a command that was refused while combined with other commands on its line: the agent SHALL rerun it once, on its own, before treating it as blocked. 2. Send one message that names the exact command, and says it downloads and runs a package from npm. -3. When the refusal came from the agent's own safety classifier rather than a missing permission (in Claude Code, a denial by the auto mode classifier), say that an allow rule cannot clear it. When the user's settings (in Claude Code, top-level `autoMode.allow` in `~/.claude/settings.json`) already hold an entry beginning `Taskless CLI:` that matches the global trust entry, the agent SHALL say the entry is present and SHALL NOT tell the user to add it. When the entry is absent, or carries the label with different text, the agent SHALL give the user the global trust entry, inline, with the file and key to add it under, after `"$defaults"`, replacing an older `Taskless CLI:` entry. The agent SHALL NOT write that entry itself. In every case it SHALL also offer running the CLI with no arguments in a terminal, which offers to add or refresh the entry, per-command approval in chat, and leaving the classifier's mode, as the alternatives. +3. When the refusal came from the agent's own safety classifier rather than a missing permission (in Claude Code, a denial by the auto mode classifier), say that an allow rule cannot clear it. When the user's settings (in Claude Code, top-level `autoMode.allow` in `~/.claude/settings.json`) already hold an entry that matches this build's global trust entry, the agent SHALL say the entry is present and SHALL NOT tell the user to add it. When the entry is absent, or carries the label with different text, the agent SHALL give the user the global trust entry, inline, with the file and key to add it under, after `"$defaults"`, replacing an older entry under this build's label. The agent SHALL NOT write that entry itself. In every case it SHALL also offer running the CLI with no arguments in a terminal, which offers to add or refresh the entry, per-command approval in chat, and leaving the classifier's mode, as the alternatives. 4. Otherwise, offer the user three ways forward: the agent adds an allow rule for exactly the invocation the skill names to the agent's permission settings, and if the harness blocks that edit too, gives the user the change to make themselves; the user approves it through their agent's permissions command (in Claude Code, `/permissions` with the rule `Bash(:*)`); or the user approves every Taskless command manually, in chat, as the agent runs it. 5. Never propose an allow rule that widens the invocation, such as a wildcard over the package name or its version. Where the harness's allowlist cannot scope to the one invocation, for example one that only allows a whole tool, offer per-command approval in chat instead of a rule. 6. Not ask in that message what the user wants Taskless to do. Their request stands, and the agent carries it out once the command is unblocked. -The global trust entry and these instructions SHALL be written in the skill body itself, not fetched from an `agent ` recipe, because a recipe is fetched through the same command that is blocked. +The global trust entry and these instructions SHALL be written in the skill body itself, the entry and its label as placeholders that install renders to the installing build's entry and label, not fetched from an `agent ` recipe, because a recipe is fetched through the same command that is blocked. #### Scenario: Skill body runs the preflight first @@ -193,7 +193,7 @@ The global trust entry and these instructions SHALL be written in the skill body - **WHEN** the skill body is read from source - **THEN** it SHALL instruct the agent, when a Taskless command is refused by the agent's safety classifier, to stop without retrying -- **AND** SHALL contain the global trust entry text inline, beginning `Taskless CLI:` +- **AND** SHALL contain this build's global trust entry inline once installed: the release's, beginning `Taskless CLI:`, from the released build, and the nightly's, beginning `Taskless CLI nightly:`, from a nightly - **AND** SHALL name `~/.claude/settings.json` and `autoMode.allow` for Claude Code - **AND** SHALL NOT offer a project allow rule for that refusal - **AND** SHALL forbid the agent from writing the entry itself diff --git a/packages/cli/src/agent/init.md b/packages/cli/src/agent/init.md index 62287da1..c52047ef 100644 --- a/packages/cli/src/agent/init.md +++ b/packages/cli/src/agent/init.md @@ -88,8 +88,9 @@ first step, not the whole job. - `globalTrust` is always present, empty when no installed harness has a classifier that overrules project rules. Each entry is that harness's user-level settings file and whether its Taskless entry is - `present`, `absent`, `stale` (a `Taskless CLI:` entry with older - text), or `skipped` (with the reason). `init` only reads it; it is + `present`, `absent`, `stale` (an entry under this CLI's label, such + as `Taskless CLI:` for the release, with older text), or `skipped` + (with the reason). `init` only reads it; it is never written by this command, and you must not write it either. When it is `absent` or `stale`, tell the user that their agent may refuse Taskless commands the project rule allows, and that the diff --git a/packages/cli/src/install/canonical.ts b/packages/cli/src/install/canonical.ts index 4b7c46b8..5fad7e56 100644 --- a/packages/cli/src/install/canonical.ts +++ b/packages/cli/src/install/canonical.ts @@ -11,6 +11,7 @@ import { withCliBuildNotice, } from "../util/invocation"; import { parseFrontmatter } from "./frontmatter"; +import { renderTrustPlaceholders } from "./global-trust"; /** * The Taskless-owned namespace that holds canonical skill content. No tool @@ -39,8 +40,9 @@ export function canonicalSkillPath(name: string): string { * Write a skill's full content to the canonical store at * `.taskless/skills//SKILL.md`. The canonical store is the single source * of truth. The source spells the CLI as `%(TASKLESS_CLI)s`, rendered here to - * this build's invocation; for `dev`/`self`/nightly builds a build notice is - * also prepended (see {@link renderInvocationPlaceholder} / + * this build's invocation, and its global trust entry and label likewise (see + * {@link renderTrustPlaceholders}); for `dev`/`self`/nightly builds a build + * notice is also prepended (see {@link renderInvocationPlaceholder} / * {@link withCliBuildNotice}). */ export async function writeCanonicalSkill( @@ -53,7 +55,9 @@ export async function writeCanonicalSkill( const path = join(directory, "SKILL.md"); const changed = await writeIfChanged( path, - withCliBuildNotice(renderInvocationPlaceholder(content)) + withCliBuildNotice( + renderTrustPlaceholders(renderInvocationPlaceholder(content)) + ) ); return { path, changed }; } diff --git a/packages/cli/src/install/global-trust.ts b/packages/cli/src/install/global-trust.ts index b85e42eb..2555184c 100644 --- a/packages/cli/src/install/global-trust.ts +++ b/packages/cli/src/install/global-trust.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import process from "node:process"; +import { isProductionInvocation } from "../util/invocation"; import { isRecord } from "../util/is-record"; import { parseJson, set, valueAt } from "./permissions"; @@ -23,34 +24,93 @@ import { parseJson, set, valueAt } from "./permissions"; * not the current one is stale, and the wizard replaces it, since we know * which text works and a hand-copied or older one may not. * + * Each package has its own entry and label, so the release and a nightly + * installed on the same machine never read each other's entry as stale. With + * one shared label, whichever build was reworded last would offer to replace + * the other's text on every run, and the other would offer to put it back. + * * The list is closed. A harness joins once it is verified that its classifier * can refuse what its project file allows, and that its user settings accept * a written exception. */ -/** The label every entry begins with, and how a present one is found. */ -const LABEL = "Taskless CLI:"; +/** One package's entry, and the label it begins with and is found by. */ +export interface GlobalTrustText { + /** The package the entry covers. */ + packageName: string; + label: string; + entry: string; +} /** - * The entry itself, identical in every version. The example version is fake on - * purpose, so the text never ages. + * The entry for one package. The example version is fake on purpose, so the + * text never ages. * - * It names both packages, in every build, so it is not routed through - * `applyCliInvocation()`: a nightly rewriting it would hand the skill and the - * user settings two different texts. + * The package names are written here, not routed through + * `applyCliInvocation()`: each entry names its own package in every build, + * which is what lets a release and a nightly each recognise their own. */ -export const GLOBAL_TRUST_ENTRY = +function trustText( + packageName: string, + label: string, + exampleVersion: string +): GlobalTrustText { + return { + packageName, + label, + entry: + `${label} The npm package ${packageName} is tooling published by ` + + "Taskless via npm trusted publishing (OIDC), installed by the user " + + "through the Taskless installer. Running it via npx or pnpm dlx is " + + "allowed only when the call names an exact version (e.g. " + + `${packageName}@${exampleVersion}). The call stays covered when the ` + + "same command line also sets a variable, changes or creates a " + + "directory, or redirects the call's output to a file; every other " + + "command on the line is judged on its own. Dist-tags such as @latest " + + "or @nightly, version ranges, and unversioned calls are not covered.", + }; +} + +export const RELEASE_TRUST = trustText( // ast-grep-ignore: no-unrouted-cli-invocation - `${LABEL} The npm packages @taskless/cli and @taskless/cli-nightly are ` + - "tooling published by Taskless via npm trusted publishing (OIDC), installed " + - "by the user through the Taskless installer. Running either via npx or pnpm " + - "dlx is allowed only when the call names an exact version (e.g. " + + "@taskless/cli", + "Taskless CLI:", + "1.2.3" +); + +export const NIGHTLY_TRUST = trustText( // ast-grep-ignore: no-unrouted-cli-invocation - "@taskless/cli@1.2.3). The call stays covered when the same command line " + - "also sets a variable, changes or creates a directory, or redirects the " + - "call's output to a file; every other command on the line is judged on " + - "its own. Dist-tags such as @latest or @nightly, version ranges, and " + - "unversioned calls are not covered."; + "@taskless/cli-nightly", + "Taskless CLI nightly:", + "1.2.3-20260101000000xabcdef0" +); + +/** + * This build's entry: the release's for the released package, the nightly's + * otherwise. A `self` build runs from a path and needs no entry of its own, + * and is a step toward a nightly, so it offers that one. + */ +export const BUILD_TRUST: GlobalTrustText = isProductionInvocation() + ? RELEASE_TRUST + : NIGHTLY_TRUST; + +/** This build's entry text. */ +export const GLOBAL_TRUST_ENTRY = BUILD_TRUST.entry; + +/** The skill source's spelling of this build's label and entry. */ +const LABEL_PLACEHOLDER = "%(TASKLESS_TRUST_LABEL)s"; +const ENTRY_PLACEHOLDER = "%(TASKLESS_TRUST_ENTRY)s"; + +/** + * Render this build's label and entry into an installed skill body, so the + * fallback an agent reads names the entry this build's wizard writes. + * Substituted as exact tokens, as the invocation placeholder is. + */ +export function renderTrustPlaceholders(content: string): string { + return content + .replaceAll(ENTRY_PLACEHOLDER, BUILD_TRUST.entry) + .replaceAll(LABEL_PLACEHOLDER, BUILD_TRUST.label); +} /** Where a harness's user settings live, from the environment. */ export interface GlobalTrustEnvironment { @@ -157,7 +217,7 @@ function inspect(trust: GlobalTrust, current = "{}\n"): Inspection { return { kind: "skip", reason: `${trust.key.join(".")} is not a list` }; } const index = list.findIndex( - (item) => typeof item === "string" && item.startsWith(LABEL) + (item) => typeof item === "string" && item.startsWith(BUILD_TRUST.label) ); if (index !== -1) { if (list[index] === GLOBAL_TRUST_ENTRY) return { kind: "present" }; @@ -234,8 +294,7 @@ export function describeGlobalTrust( .filter((outcome) => needsWrite(outcome)) .map((outcome) => ({ replace: outcome.state === "stale", - // ast-grep-ignore: no-unrouted-cli-invocation - what: "@taskless/cli or @taskless/cli-nightly at an exact version", + what: `${BUILD_TRUST.packageName} at an exact version`, where: `in ${display(outcome.file, home)} (${outcome.key})`, })); } @@ -257,7 +316,7 @@ export function globalTrustWarnings( const wizard = `run \`${installer}\` with no arguments in a terminal`; const fix = outcome.state === "stale" - ? `The \`Taskless CLI:\` entry in ${list} is out of date. Replace it with this one, or ${wizard}, which offers to replace it` + ? `The \`${BUILD_TRUST.label}\` entry in ${list} is out of date. Replace it with this one, or ${wizard}, which offers to replace it` : `To trust Taskless, add this entry to ${list}` + (outcome.state === "skipped" ? ` (left alone: ${outcome.skipped})` diff --git a/packages/cli/src/install/permissions.ts b/packages/cli/src/install/permissions.ts index b1e50e0b..3680f890 100644 --- a/packages/cli/src/install/permissions.ts +++ b/packages/cli/src/install/permissions.ts @@ -36,10 +36,12 @@ import { isRecord } from "../util/is-record"; * * - The rule is never wider than the invocation. Each uses that harness's * word-bounded form, so `@0.13.0` cannot match `@0.13.01`. - * - A file holds exactly one Taskless rule set: the installed invocation's. - * Every other allow rule for a Taskless invocation is removed, however it - * got there, since a pinned rule for a version that is no longer installed - * allows nothing anyone wants. Deny rules, and rules for anything else, are + * - A file holds exactly one Taskless rule set per package: the installed + * invocation's. Every other allow rule for the same package is removed, + * however it got there, since a pinned rule for a version that is no longer + * installed allows nothing anyone wants. The other package's rules are left + * to the other package, so a nightly and the release installed side by side + * do not undo each other. Deny rules, and rules for anything else, are * never touched. The manifest record decides only whether a file Taskless * created may be deleted once it is empty. * - A file that denies the invocation, or does not parse, is left alone. @@ -408,11 +410,19 @@ function cursorCliDenies(root: unknown, invocation: string): boolean { * package name must end there, so `@taskless/cli-foo` is not Taskless. */ const TASKLESS_COMMAND = - /^(?:npx|pnpm dlx) @taskless\/cli(?:-nightly)?(?:@[^\s]+)?$/; + /^(?:npx|pnpm dlx) (@taskless\/cli(?:-nightly)?)(?:@[^\s]+)?$/; + +/** + * The Taskless package `command` runs, less a trailing ` *` glob, or + * `undefined` when it does not run the Taskless CLI. + */ +function tasklessPackage(command: string): string | undefined { + return TASKLESS_COMMAND.exec(command.replace(/ \*$/, ""))?.[1]; +} /** Whether `command`, less a trailing ` *` glob, runs the Taskless CLI. */ function isTasklessCommand(command: string): boolean { - return TASKLESS_COMMAND.test(command.replace(/ \*$/, "")); + return tasklessPackage(command) !== undefined; } /** The strings in the list at `path` that `isTaskless` accepts. */ @@ -797,9 +807,14 @@ export interface SyncPermissionsOptions { /** * Bring every harness permission file in line with this install: add the rule - * for the invocation, remove every other Taskless rule in the file, and every - * Taskless rule from a recorded file whose harness is no longer selected, and - * record what it wrote. + * for the invocation, remove every other rule in the file for the invocation's + * package, and every such rule from a recorded file whose harness is no longer + * selected, and record what it wrote. + * + * Rules for the other package are neither removed nor dropped from the + * record: they belong to the other build, which keeps them current on its own + * runs. An invocation that names neither package (a `self` build's path) + * removes no package rules at all. * * With `write: false` it reads the same files and reports the same edits * without touching anything, which is what the wizard previews. @@ -809,6 +824,13 @@ export async function syncPermissions( options: SyncPermissionsOptions ): Promise { const previous = await readRecords(cwd); + const own = tasklessPackage(options.invocation); + const packageOf = (rule: string) => tasklessPackage(commandOfRule(rule)); + const isOwn = (rule: string) => own !== undefined && packageOf(rule) === own; + const isOther = (rule: string) => { + const name = packageOf(rule); + return name !== undefined && name !== own; + }; const desired = new Map(); for (const harness of harnessesFor(options.selected, options.codexDetected)) { desired.set(await harness.locate(cwd), { @@ -832,7 +854,9 @@ export async function syncPermissions( const text = await readText(path); const want = desired.get(file)?.rules ?? []; const had = recorded?.rules ?? []; - const remove = harness.found(text).filter((rule) => !want.includes(rule)); + const remove = harness + .found(text) + .filter((rule) => isOwn(rule) && !want.includes(rule)); const add = want.filter((rule) => !had.includes(rule)); if (add.length === 0 && remove.length === 0) { if (recorded !== undefined) next[file] = recorded; @@ -858,7 +882,7 @@ export async function syncPermissions( const removed = remove; const rules = [ - ...had.filter((rule) => want.includes(rule)), + ...had.filter((rule) => want.includes(rule) || isOther(rule)), ...result.added, ]; if (rules.length > 0) next[file] = { rules, created }; diff --git a/packages/cli/test/apply-install-plan.test.ts b/packages/cli/test/apply-install-plan.test.ts index d325b694..a9368600 100644 --- a/packages/cli/test/apply-install-plan.test.ts +++ b/packages/cli/test/apply-install-plan.test.ts @@ -17,6 +17,7 @@ import { buildInstallPlan, getEmbeddedSkills, } from "../src/install/install"; +import { renderTrustPlaceholders } from "../src/install/global-trust"; import { renderInvocationPlaceholder } from "../src/util/invocation"; import { isShimStub, stubRecoveryInvocation } from "../src/install/canonical"; import { parseFrontmatter } from "../src/install/frontmatter"; @@ -65,13 +66,16 @@ describe("applyInstallPlan", () => { const result = await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); // Canonical store holds the source with `%(TASKLESS_CLI)s` rendered to - // this build's invocation, which is the only substitution it makes. + // this build's invocation, and the trust placeholders to this build's + // entry, which are the only substitutions it makes. const canonical = await readFile( join(cwd, ".taskless", "skills", "taskless", "SKILL.md"), "utf8" ); expect(canonical).toBe( - renderInvocationPlaceholder(tasklessSkill().content) + renderTrustPlaceholders( + renderInvocationPlaceholder(tasklessSkill().content) + ) ); // The .claude target holds a delegating stub, not the full content. diff --git a/packages/cli/test/global-trust.test.ts b/packages/cli/test/global-trust.test.ts index f3820354..875a15a2 100644 --- a/packages/cli/test/global-trust.test.ts +++ b/packages/cli/test/global-trust.test.ts @@ -7,6 +7,8 @@ import { describeGlobalTrust, GLOBAL_TRUST_ENTRY, globalTrustWarnings, + NIGHTLY_TRUST, + RELEASE_TRUST, syncGlobalTrust, type GlobalTrustEnvironment, } from "../src/install/global-trust"; @@ -53,6 +55,23 @@ describe("the entry", () => { expect(GLOBAL_TRUST_ENTRY).toContain("not covered"); }); + it("is the release's entry in the released build", () => { + expect(GLOBAL_TRUST_ENTRY).toBe(RELEASE_TRUST.entry); + }); + + it("names one package per entry, under labels that never match each other", () => { + expect(RELEASE_TRUST.entry.startsWith("Taskless CLI:")).toBe(true); + expect(RELEASE_TRUST.entry).toContain("@taskless/cli is"); + expect(RELEASE_TRUST.entry).not.toContain("cli-nightly"); + + expect(NIGHTLY_TRUST.entry.startsWith("Taskless CLI nightly:")).toBe(true); + expect(NIGHTLY_TRUST.entry).toContain("@taskless/cli-nightly is"); + expect(NIGHTLY_TRUST.entry.match(/\d+\.\d+\.\d+/g)).toEqual(["1.2.3"]); + + expect(NIGHTLY_TRUST.entry.startsWith(RELEASE_TRUST.label)).toBe(false); + expect(RELEASE_TRUST.entry.startsWith(NIGHTLY_TRUST.label)).toBe(false); + }); + it("covers the call inside shell plumbing, and only the call", () => { expect(GLOBAL_TRUST_ENTRY).toContain( "sets a variable, changes or creates a directory, or redirects the call's output to a file" @@ -175,6 +194,22 @@ describe("syncGlobalTrust", () => { }); }); + it("leaves the nightly's entry alone", async () => { + await put( + JSON.stringify({ + autoMode: { allow: ["$defaults", NIGHTLY_TRUST.entry] }, + }) + ); + const outcomes = await sync(true); + + expect(outcomes[0]?.state).toBe("written"); + expect(JSON.parse(await read())).toEqual({ + autoMode: { + allow: ["$defaults", NIGHTLY_TRUST.entry, GLOBAL_TRUST_ENTRY], + }, + }); + }); + it("never reads an unlabelled entry as ours", async () => { await put( JSON.stringify({ autoMode: { allow: ["Internal Registry: Taskless"] } }) @@ -248,7 +283,7 @@ describe("rendering", () => { expect(describeGlobalTrust(outcomes, home)).toEqual([ { replace: false, - what: "@taskless/cli or @taskless/cli-nightly at an exact version", + what: "@taskless/cli at an exact version", where: "in ~/.claude/settings.json (autoMode.allow)", }, ]); diff --git a/packages/cli/test/installed-documentation.test.ts b/packages/cli/test/installed-documentation.test.ts index 170d3ef8..30c66884 100644 --- a/packages/cli/test/installed-documentation.test.ts +++ b/packages/cli/test/installed-documentation.test.ts @@ -274,18 +274,36 @@ describe("this repository's own installed Taskless docs", () => { "tell the user it is present and do not ask them to add it" ); expect(prose).toContain( - "in place of any older entry that begins `Taskless CLI:`" + "in place of any older entry that begins `%(TASKLESS_TRUST_LABEL)s`" ); }); it("carries the same global trust entry init writes", async () => { // One text in two places: if they drift, the skill hands over an entry - // init would not recognise, or the reverse. - const skill = await readFile( + // init would not recognise, or the reverse. The source spells it as a + // placeholder so each build installs its own package's entry. + const source = await readFile( resolve(repositoryRoot, "skills", "taskless", "SKILL.md"), "utf8" ); - expect(skill).toContain(`\n${GLOBAL_TRUST_ENTRY}\n`); + expect(source).toContain("\n%(TASKLESS_TRUST_ENTRY)s\n"); + + const cwd = await mkdtemp(join(tmpdir(), "taskless-trust-entry-")); + try { + await applyInstallPlan( + cwd, + buildInstallPlan([".claude"], getEmbeddedSkills()), + { cliVersion: "0.0.0" } + ); + const installed = await readFile( + join(cwd, ".taskless", "skills", "taskless", "SKILL.md"), + "utf8" + ); + expect(installed).toContain(`\n${GLOBAL_TRUST_ENTRY}\n`); + expect(installed).not.toContain("%(TASKLESS_TRUST"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } }); it("keeps the skill body within its 200-line cap", async () => { diff --git a/packages/cli/test/nightly/global-trust-nightly.test.ts b/packages/cli/test/nightly/global-trust-nightly.test.ts new file mode 100644 index 00000000..20c72eed --- /dev/null +++ b/packages/cli/test/nightly/global-trust-nightly.test.ts @@ -0,0 +1,91 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + GLOBAL_TRUST_ENTRY, + NIGHTLY_TRUST, + RELEASE_TRUST, + syncGlobalTrust, + type GlobalTrustEnvironment, +} from "../../src/install/global-trust"; +import { + applyInstallPlan, + buildInstallPlan, + getEmbeddedSkills, +} from "../../src/install/install"; + +/** + * Under the `nightly` vitest project (see `vite.config.ts`), a nightly owns + * its own `Taskless CLI nightly:` entry, so testing one beside the release + * never offers to replace the release's entry, or the reverse. + */ +describe("a nightly's global trust entry", () => { + let home: string; + let environment: GlobalTrustEnvironment; + let settings: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), "taskless-nightly-trust-")); + environment = { home, env: {} }; + settings = join(home, ".claude", "settings.json"); + }); + + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + }); + + it("is the nightly's entry", () => { + expect(GLOBAL_TRUST_ENTRY).toBe(NIGHTLY_TRUST.entry); + }); + + it("adds its own entry beside the release's, and leaves that one alone", async () => { + await mkdir(dirname(settings), { recursive: true }); + await writeFile( + settings, + JSON.stringify({ + autoMode: { allow: ["$defaults", RELEASE_TRUST.entry] }, + }) + ); + const outcomes = await syncGlobalTrust({ + selected: [".claude"], + write: true, + environment, + }); + + expect(outcomes[0]?.state).toBe("written"); + expect(JSON.parse(await readFile(settings, "utf8"))).toEqual({ + autoMode: { + allow: ["$defaults", RELEASE_TRUST.entry, NIGHTLY_TRUST.entry], + }, + }); + const again = await syncGlobalTrust({ + selected: [".claude"], + write: false, + environment, + }); + expect(again[0]?.state).toBe("present"); + }); + + it("installs a skill that hands over the nightly's entry", async () => { + const cwd = await mkdtemp(join(tmpdir(), "taskless-nightly-trust-skill-")); + try { + await applyInstallPlan( + cwd, + buildInstallPlan([".claude"], getEmbeddedSkills()), + { cliVersion: "0.0.0-nightly.test" } + ); + const skill = await readFile( + join(cwd, ".taskless", "skills", "taskless", "SKILL.md"), + "utf8" + ); + expect(skill).toContain(`\n${NIGHTLY_TRUST.entry}\n`); + expect(skill).toContain( + `in place of any older entry that begins \`${NIGHTLY_TRUST.label}\`` + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/test/permissions.test.ts b/packages/cli/test/permissions.test.ts index 60590e83..2bd9ddc9 100644 --- a/packages/cli/test/permissions.test.ts +++ b/packages/cli/test/permissions.test.ts @@ -20,6 +20,8 @@ import { const V1 = "npx @taskless/cli@0.13.0"; const V2 = "npx @taskless/cli@0.13.1"; +const N1 = "npx @taskless/cli-nightly@0.13.2-20261016000000xaaaaaaa"; +const N2 = "npx @taskless/cli-nightly@0.13.2-20261017000000xbbbbbbb"; let cwd: string; @@ -172,11 +174,11 @@ describe("upgrades", () => { ".claude/settings.json", JSON.stringify({ permissions: { allow: [stale] } }) ); - const edits = await sync({ selected: [".claude"], invocation: V1 }); + const edits = await sync({ selected: [".claude"], invocation: N1 }); expect(edits[0]?.removed).toEqual([stale]); expect(await json(".claude/settings.json")).toEqual({ - permissions: { allow: [`Bash(${V1} *)`] }, + permissions: { allow: [`Bash(${N1} *)`] }, }); }); @@ -268,6 +270,76 @@ describe("upgrades", () => { }); }); +describe("the release and the nightly side by side", () => { + it("keeps each other's rules", async () => { + await sync({ selected: [".claude"], invocation: V1 }); + const edits = await sync({ selected: [".claude"], invocation: N1 }); + + expect(edits[0]).toMatchObject({ + allowed: [`Bash(${N1} *)`], + removed: [], + }); + expect(await sync({ selected: [".claude"], invocation: V1 })).toEqual([]); + expect(await sync({ selected: [".claude"], invocation: N1 })).toEqual([]); + expect(await json(".claude/settings.json")).toEqual({ + permissions: { allow: [`Bash(${V1} *)`, `Bash(${N1} *)`] }, + }); + }); + + it("upgrades only its own package's rule", async () => { + await sync({ selected: [".claude"], invocation: V1 }); + await sync({ selected: [".claude"], invocation: N1 }); + const edits = await sync({ selected: [".claude"], invocation: N2 }); + + expect(edits[0]).toMatchObject({ + allowed: [`Bash(${N2} *)`], + removed: [`Bash(${N1} *)`], + }); + expect(await json(".claude/settings.json")).toEqual({ + permissions: { allow: [`Bash(${V1} *)`, `Bash(${N2} *)`] }, + }); + }); + + it("leaves an unrecorded rule for the other package alone", async () => { + const nightly = + "Bash(npx @taskless/cli-nightly@0.13.0-20261015220659xbf27a23:*)"; + await put( + ".claude/settings.json", + JSON.stringify({ permissions: { allow: [nightly] } }) + ); + const edits = await sync({ selected: [".claude"], invocation: V1 }); + + expect(edits[0]?.removed).toEqual([]); + expect(await json(".claude/settings.json")).toEqual({ + permissions: { allow: [nightly, `Bash(${V1} *)`] }, + }); + }); + + it("keeps the other package's record, so a created file is still deleted", async () => { + await sync({ selected: [".claude"], invocation: V1 }); + await sync({ selected: [".claude"], invocation: N1 }); + + await sync({ selected: [], invocation: V1 }); + expect(await json(".claude/settings.json")).toEqual({ + permissions: { allow: [`Bash(${N1} *)`] }, + }); + + await sync({ selected: [], invocation: N1 }); + expect(await exists(".claude/settings.json")).toBe(false); + }); + + it("removes no package rules for an invocation that names neither", async () => { + await sync({ selected: [".claude"], invocation: V1 }); + await sync({ selected: [".claude"], invocation: N1 }); + const edits = await sync({ + selected: [".claude"], + invocation: "node ./packages/cli/dist/index.js", + }); + + expect(edits[0]?.removed).toEqual([]); + }); +}); + describe("Cursor", () => { it("writes the editor allowlist and the CLI rules", async () => { await sync({ selected: [".cursor"] }); diff --git a/packages/cli/test/wizard-integration.test.ts b/packages/cli/test/wizard-integration.test.ts index f0cede69..13053055 100644 --- a/packages/cli/test/wizard-integration.test.ts +++ b/packages/cli/test/wizard-integration.test.ts @@ -212,9 +212,8 @@ describe("runWizard end-to-end", () => { }); it("previews removing a Taskless rule it never recorded", async () => { - // The stale nightly rule an agent added through the skill's fallback. - const stale = - "Bash(npx @taskless/cli-nightly@0.13.0-20261015220659xbf27a23 *)"; + // A stale rule an agent added through the skill's fallback. + const stale = "Bash(npx @taskless/cli@0.12.0 *)"; await writeFile( join(cwd, ".claude", "settings.json"), JSON.stringify({ permissions: { allow: [stale] } }) @@ -228,7 +227,7 @@ describe("runWizard end-to-end", () => { const summary = loggedMessages.join("\n"); // REMOVE is the widest label, so ALLOW pads to meet it. expect(summary).toContain( - "REMOVE (project): npx @taskless/cli-nightly@0.13.0-20261015220659xbf27a23\n" + + "REMOVE (project): npx @taskless/cli@0.12.0\n" + " in .claude/settings.json" ); expect(summary).toMatch(/ALLOW \(project\): {2}npx @taskless\/cli@/); @@ -253,7 +252,7 @@ describe("runWizard end-to-end", () => { /ALLOW \(project\): npx @taskless\/cli@\S+\n {17}in \.claude\/settings\.json/ ); expect(summary).toContain( - "ALLOW (global): @taskless/cli or @taskless/cli-nightly at an exact version\n" + + "ALLOW (global): @taskless/cli at an exact version\n" + " in ~/.claude/settings.json (autoMode.allow)" ); expect(confirmMessages).toEqual(["Make these changes to your settings?"]); @@ -350,7 +349,7 @@ describe("runWizard end-to-end", () => { await runWizard({ cwd, globalTrustEnvironment }); expect(loggedMessages.join("\n")).toContain( - "REPLACE (global): @taskless/cli or @taskless/cli-nightly at an exact version\n" + + "REPLACE (global): @taskless/cli at an exact version\n" + " in ~/.claude/settings.json (autoMode.allow)" ); expect(confirmMessages).toEqual(["Make these changes to your settings?"]); diff --git a/skills/taskless/SKILL.md b/skills/taskless/SKILL.md index b21e8adb..525e2307 100644 --- a/skills/taskless/SKILL.md +++ b/skills/taskless/SKILL.md @@ -63,10 +63,10 @@ clear it: the classifier overrules project rules. Read the top-level `autoMode.allow` list in `~/.claude/settings.json`. If it already holds the entry below, word for word, tell the user it is present and do not ask them to add it. Otherwise give them this entry, to add themselves after `"$defaults"`, -in place of any older entry that begins `Taskless CLI:`: +in place of any older entry that begins `%(TASKLESS_TRUST_LABEL)s`: ```text -Taskless CLI: The npm packages @taskless/cli and @taskless/cli-nightly are tooling published by Taskless via npm trusted publishing (OIDC), installed by the user through the Taskless installer. Running either via npx or pnpm dlx is allowed only when the call names an exact version (e.g. @taskless/cli@1.2.3). The call stays covered when the same command line also sets a variable, changes or creates a directory, or redirects the call's output to a file; every other command on the line is judged on its own. Dist-tags such as @latest or @nightly, version ranges, and unversioned calls are not covered. +%(TASKLESS_TRUST_ENTRY)s ``` Never write that entry yourself: an agent widening its own classifier's trust From bf2b8abcea11b491e01d637648e4ef42b81daf4e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 17 Oct 2026 02:14:27 -0700 Subject: [PATCH 2/2] test(init): pin side-by-side release and nightly rules for every harness --- packages/cli/test/permissions.test.ts | 46 +++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/cli/test/permissions.test.ts b/packages/cli/test/permissions.test.ts index 2bd9ddc9..c56108c4 100644 --- a/packages/cli/test/permissions.test.ts +++ b/packages/cli/test/permissions.test.ts @@ -338,6 +338,52 @@ describe("the release and the nightly side by side", () => { expect(edits[0]?.removed).toEqual([]); }); + + // The scoping lives in `syncPermissions()`, but each harness finds and + // edits its own format, and Codex rewrites its whole file. + it.each([ + { + harness: "Cursor", + selected: [".cursor"], + files: [".cursor/permissions.json", ".cursor/cli.json"], + }, + { + harness: "OpenCode, object form", + selected: [".opencode"], + files: [".opencode/opencode.json"], + }, + { + harness: "OpenCode, array form", + selected: [".opencode"], + files: ["opencode.json"], + seed: '{ "permissions": [] }\n', + }, + { + harness: "Codex", + selected: [".agents"], + codexDetected: true, + files: [".codex/rules/taskless.rules"], + }, + ])( + "keeps each other's rules in $harness", + async ({ selected, codexDetected = false, files, seed }) => { + if (seed !== undefined) await put(files[0] ?? "", seed); + const run = (invocation: string) => + sync({ selected, codexDetected, invocation }); + await run(V1); + await run(N1); + await run(N2); + + expect(await run(V1)).toEqual([]); + expect(await run(N2)).toEqual([]); + for (const file of files) { + const contents = await text(file); + expect(contents).toContain("@taskless/cli@0.13.0"); + expect(contents).toContain("0.13.2-20261017000000xbbbbbbb"); + expect(contents).not.toContain("0.13.2-20261016000000xaaaaaaa"); + } + } + ); }); describe("Cursor", () => {