Skip to content
Merged
9 changes: 9 additions & 0 deletions .changeset/refresh-global-trust-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@taskless/cli": patch
---

The Claude Code trust entry now stays current, and Taskless commands stop tripping auto mode by being chained.

- Running `npx @taskless/cli` with no arguments now offers to replace a `Taskless CLI:` entry in `~/.claude/settings.json` whose text is out of date, under the same one question it asks for every settings edit. `init` still never writes outside the project: it warns, and `init --json` reports the entry as `stale`.
- The entry also covers a pinned Taskless call when the same command line sets a variable, changes or creates a directory, or redirects the call's output to a file. Every other command on the line is still judged on its own.
- The installed skill tells agents to run each Taskless command on its own line, and when auto mode refuses one while the entry is already present, to say so rather than asking the user to add it again.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-16
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
## Context

`packages/cli/src/install/global-trust.ts` owns the entry: `inspect()` classifies a settings file as present, absent, or skip by looking for any string beginning `Taskless CLI:`, and `syncGlobalTrust()` writes only for `absent`. The wizard previews with `write: false`, shows `describeGlobalTrust()` lines in the summary, and writes on confirm; `init` only reads and warns through `globalTrustWarnings()`. `skills/taskless/SKILL.md` carries the same entry text inline for the classifier fallback, and `installed-documentation.test.ts` pins that the two texts agree.

## Goals / Non-Goals

**Goals:**

- One entry text, used by the wizard, the warning, and the skill body.
- An existing labelled entry converges on that text through the path a person already runs to update.

**Non-Goals:**

- Prompting from `init` in a TTY. `init` is the batch install for agents, scripts, and a person upgrading without prompts; the bare CLI is how a person updates, and it already asks.
- Covering arbitrary commands chained to a Taskless call. The entry is a statement about one package, not a general shell allowance.
- Any write outside the project from a non-interactive run.

## Decisions

**Stale is "labelled, text differs", not "matches a past wording we shipped".** The CLI has shipped exactly one wording, and the entry that was refused was hand-copied text, not that one. A list of historical wordings would not have caught the case that motivated this. The label is the ownership marker: a string beginning `Taskless CLI:` is ours, and we know which text works under it, so any other text under that label is always replaced with the current entry, with the user's yes in the wizard. A user who wants their own wording uses a different label, which the CLI never reads. Alternative considered: never rewrite, warn only. Rejected, since that leaves every existing user on whatever text they have forever.

**Replace in place, first labelled string only.** `inspect()` finds the index of the first labelled string and sets that index; position in the list is preserved, so a user who ordered their entries keeps that order. Duplicates beyond the first are left alone rather than deleted: removing strings from a user file is a larger edit than the one the summary line describes.

**`stale` is a fourth outcome state, not a flag on `absent`.** It flows unchanged through `--json` (`globalTrust[].state`), the summary (`REPLACE (global):`), and the warning, each of which needs to word it differently. `syncGlobalTrust({ write: true })` writes for `absent` and `stale`; the wizard's "anything to confirm" check becomes the same pair.

**The widened sentence names plumbing, not commands.** It lists the shapes agents actually produce (a variable assignment, `cd`/`mkdir`, a redirect of the call's output to a file) and says every other command on the line is judged on its own. Naming pipes was rejected: piping the output of downloaded code into another command is exactly the shape the classifier is right to look at.

**The skill fixes the cause; the entry is the backstop.** The standalone rule in the skill body is what stops agents producing the refused shape; the widened entry only keeps a slip from ending the session. The fallback's "present entry" branch needs the agent to read `~/.claude/settings.json`, which is a read of the user's own settings and not the self-modification the classifier refuses.

## Risks / Trade-offs

- [The classifier may still refuse plumbing despite the sentence] → The skill's standalone rule means the plumbing should not occur; the rerun-once-on-its-own instruction recovers when it does.
- [Skill body growth against its line cap] → The new guidance brought the body to exactly 150 lines, the cap at the time, so the cap is raised to 200 per the skill spec rather than compressing guidance an agent acts on.
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
## Why

A user onboarding in Claude Code's auto mode had a `Taskless CLI:` entry in `autoMode.allow` and was still stopped. The agent had wrapped the pinned call in shell plumbing to stash its output, `S=<scratchpad> && mkdir -p $S && npx @taskless/cli@<version> …`, and the classifier refused it as `[Untrusted Code Integration]`. The entry speaks of "running" the package, so a command line that starts with something else, and writes the downloaded tool's output somewhere, is not obviously what it covers. The skill's fallback then told the user to add the entry they already had, which is wrong advice and the end of the onboarding.

The user's entry was also not the current text. It was hand-copied from an earlier fallback message, and since a present entry is never rewritten, no install or update could ever bring it up to date. Any improvement to the wording reaches only users who do not have an entry yet.

## What Changes

- **The skill body tells the agent to run every Taskless command on its own.** The command line starts with the pinned invocation and has nothing chained to it: no variable assignment, `cd`, `mkdir`, `&&`, `;`, pipe, or redirect. Output is read from the call's result; a file a recipe asks for is written with the agent's file tools. This is the fix for the refusal itself.
- **The classifier fallback distinguishes a present entry.** When `autoMode.allow` already holds a `Taskless CLI:` entry, the agent does not tell the user to add it. It says the entry is present, names the refused command, and, when that command was not a standalone invocation, reruns it as one; a standalone call refused anyway goes to per-command approval or running the wizard to refresh the entry.
- **The entry text is widened, narrowly.** It additionally says the Taskless 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, and that every other command on the line is judged on its own. Running at an exact version stays required; dist-tags, ranges and unversioned calls stay uncovered.
- **Install and update refresh an out-of-date entry.** An entry is `stale` when it carries the `Taskless CLI:` label and its text differs from the current entry. The wizard (both a first install and an update, since updating is running the bare CLI) shows a `REPLACE (global):` line and replaces it under the same one confirmation it already asks. **BREAKING** for the current spec, which says a present entry is never rewritten: the label is Taskless's, and an entry carrying it is Taskless's to keep current, with the user's yes.
- **`init` still never writes outside the project.** It warns when the entry is stale as it does when it is absent, and `init --json` reports `state: "stale"`.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `agent-global-trust`: the entry's wording gains the shell-plumbing sentence; detection gains a `stale` state; the wizard offers to replace a stale entry; the missing-entry warning covers a stale one.
- `skill-taskless`: the body requires standalone Taskless commands, and the classifier-refusal fallback handles an entry that is already present.

## Impact

- `packages/cli/src/install/global-trust.ts`: the entry text, the `stale` state, replacement in place of the stale string, the summary line and warning for it.
- `packages/cli/src/wizard/index.ts`, `packages/cli/src/wizard/steps/summary.ts`: a stale entry counts as a settings edit and is written on confirm.
- `packages/cli/src/agent/init.md`: the `globalTrust[].state` values gain `stale`.
- `skills/taskless/SKILL.md`: the standalone-command rule, the present-entry fallback, and the new entry text inline.
- Tests: `packages/cli/test/global-trust.test.ts`, `wizard-integration.test.ts`, `init-no-interactive.test.ts`, `installed-documentation.test.ts`. User settings stay injected; no test touches the real home directory.
- Delivery shape: **single PR**. The three pieces share one entry text and one fallback, and together are a few hundred lines.
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
## MODIFIED Requirements

### 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.

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.

#### 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

#### 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 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.

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

### 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.

`--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", <entry>]`, 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 `Taskless CLI:` entry
- **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", <entry>, "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", <entry>]`
- **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: ...", <entry>]`

#### 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 `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.

#### 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 `Taskless CLI:` entry 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`
Loading
Loading