diff --git a/.changeset/refresh-global-trust-entry.md b/.changeset/refresh-global-trust-entry.md new file mode 100644 index 00000000..1df137db --- /dev/null +++ b/.changeset/refresh-global-trust-entry.md @@ -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. diff --git a/openspec/changes/archive/2026-10-16-refresh-global-trust-entry/.openspec.yaml b/openspec/changes/archive/2026-10-16-refresh-global-trust-entry/.openspec.yaml new file mode 100644 index 00000000..0cb47656 --- /dev/null +++ b/openspec/changes/archive/2026-10-16-refresh-global-trust-entry/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-16 diff --git a/openspec/changes/archive/2026-10-16-refresh-global-trust-entry/design.md b/openspec/changes/archive/2026-10-16-refresh-global-trust-entry/design.md new file mode 100644 index 00000000..4385265e --- /dev/null +++ b/openspec/changes/archive/2026-10-16-refresh-global-trust-entry/design.md @@ -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. diff --git a/openspec/changes/archive/2026-10-16-refresh-global-trust-entry/proposal.md b/openspec/changes/archive/2026-10-16-refresh-global-trust-entry/proposal.md new file mode 100644 index 00000000..403b2da7 --- /dev/null +++ b/openspec/changes/archive/2026-10-16-refresh-global-trust-entry/proposal.md @@ -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= && mkdir -p $S && npx @taskless/cli@ …`, 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. diff --git a/openspec/changes/archive/2026-10-16-refresh-global-trust-entry/specs/agent-global-trust/spec.md b/openspec/changes/archive/2026-10-16-refresh-global-trust-entry/specs/agent-global-trust/spec.md new file mode 100644 index 00000000..c71ce2ba --- /dev/null +++ b/openspec/changes/archive/2026-10-16-refresh-global-trust-entry/specs/agent-global-trust/spec.md @@ -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", ]`, 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", , "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 `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` diff --git a/openspec/changes/archive/2026-10-16-refresh-global-trust-entry/specs/skill-taskless/spec.md b/openspec/changes/archive/2026-10-16-refresh-global-trust-entry/specs/skill-taskless/spec.md new file mode 100644 index 00000000..1e8ca1ef --- /dev/null +++ b/openspec/changes/archive/2026-10-16-refresh-global-trust-entry/specs/skill-taskless/spec.md @@ -0,0 +1,137 @@ +## ADDED Requirements + +### Requirement: Taskless commands run on their own + +The skill body SHALL instruct the agent to run every Taskless command as a command line of its own that begins with the pinned invocation, with nothing before or after it: no variable assignment, `cd`, `mkdir`, `&&`, `;`, pipe, or output redirect. The body SHALL say why: a permission rule and a classifier exception both recognise the pinned call, and a line that starts with anything else, or hands the call's output to another command, is judged as something else. The agent SHALL read the call's output from its result, and SHALL write a file a recipe asks for with its own file tools rather than through a redirect. + +#### Scenario: Skill body requires standalone commands + +- **WHEN** the skill body is read from source +- **THEN** it SHALL instruct the agent to run each Taskless command on its own line, beginning with `%(TASKLESS_CLI)s` +- **AND** SHALL name variable assignments, `cd`, `mkdir`, `&&`, pipes, and redirects as things not to combine with it + +#### Scenario: Files go through the agent's file tools + +- **WHEN** the skill body is read from source +- **THEN** it SHALL instruct the agent to write a file a recipe asks for with its file tools, not by redirecting a Taskless command's output + +## MODIFIED Requirements + +### Requirement: Skill body is a router, not an inline recipe + +The consolidated skill body SHALL NOT contain step-by-step instructions for any individual Taskless task. The body SHALL be a router that: + +1. States explicitly that the agent does NOT have the steps for any Taskless action in its context. +2. Instructs the agent to fetch the canonical recipe via `npx @taskless/cli help ` before proceeding. +3. Provides a topic disambiguation table mapping user intents to topic names. The table SHALL include a row for the new `onboard` topic. +4. Includes a `## --anonymous` section explaining the global flag's behavior. +5. Includes a first-step `.taskless/` presence check with graceful failure ("ask the user to confirm they meant Taskless"). +6. Includes a `## Quiet suggestion` (or equivalently named) section governing the proactive trigger introduced via the description's named-tool clause. This section SHALL specify that: + - When the skill triggers because the user wants to add a rule and has not named a specific tool, the agent SHALL surface a single-line offer to capture the rule via Taskless rather than launching into a full recipe (e.g., "I can capture this as a Taskless rule if you want — say so, or I'll proceed with X"). + - If the user declines or ignores the offer, the agent SHALL proceed with whatever it would have done without the skill. + - If the user declines, the agent SHALL NOT re-offer Taskless in the same conversation. No persistent decline state SHALL be written to disk. + - If the user accepts, the skill router SHALL proceed normally to fetch `npx @taskless/cli help rule create`. +7. States that a recipe is resolved when it is fetched and is not reusable across tasks: the agent SHALL fetch the recipe at the start of every Taskless task, including a topic it already fetched earlier in the same session, and SHALL NOT act on an earlier copy. +8. Names the CLI through the `%(TASKLESS_CLI)s` placeholder everywhere it spells an invocation, never as the literal `npx @taskless/cli`. The install renders the placeholder to the build's invocation, so a nightly or path-form build serves a skill that names itself. + +The body SHALL be no more than 200 lines of markdown. The cap guards against unbounded growth of a body loaded on every Taskless task; it SHALL NOT be met by compressing guidance the router needs. When the body needs more room to route correctly, the cap is raised instead. (Earlier caps of 60 and then 80 lines forced exactly that compression; 150 was reached exactly by the standalone-command rule and the present-entry fallback.) + +#### Scenario: Skill body warns against improvising + +- **WHEN** the skill body is read by an agent +- **THEN** it SHALL contain explicit framing such as "You do NOT have the steps... do not improvise from prior knowledge" + +#### Scenario: Skill body spells the invocation as a placeholder + +- **WHEN** the skill body is read from source +- **THEN** every CLI invocation SHALL be written as `%(TASKLESS_CLI)s …` +- **AND** the body SHALL NOT contain the literal `npx @taskless/cli` + +#### Scenario: Skill body forbids reusing a fetched recipe + +- **WHEN** the skill body is read by an agent +- **THEN** it SHALL state that a recipe is resolved at fetch time and that each Taskless task fetches its recipe again, even for a topic fetched earlier in the session + +#### Scenario: Skill body lists available topics including onboard + +- **WHEN** the skill body is read by an agent +- **THEN** it SHALL include a table or list mapping user intents to the corresponding `%(TASKLESS_CLI)s agent ` invocations +- **AND** the table SHALL include a row for `onboard` mapped to `npx @taskless/cli agent onboard` (or equivalent invocation of the onboard topic) + +#### Scenario: Skill body checks for .taskless directory + +- **WHEN** the skill is invoked +- **THEN** the body's first step SHALL instruct the agent to check whether `.taskless/` exists in the working directory +- **AND** to ask the user to confirm Taskless is what they meant if the directory is absent + +#### Scenario: Skill body specifies quiet suggestion behavior + +- **WHEN** the skill is triggered by the unspecified-tool clause from the description +- **THEN** the body's quiet-suggestion section SHALL instruct the agent to surface a single-line offer rather than a full recipe +- **AND** SHALL instruct the agent NOT to re-offer in the same conversation if declined +- **AND** SHALL specify that no persistent decline state is written + +#### Scenario: Skill body specifies in-conversation decline is sticky + +- **WHEN** the user has declined a quiet-suggestion offer once in the current conversation +- **THEN** the body SHALL instruct the agent not to surface the offer again in the same conversation + +#### Scenario: Skill body length cap + +- **WHEN** the skill body is measured +- **THEN** it SHALL be no more than 200 lines of markdown + +### 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 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. +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. + +#### 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 the global trust entry text inline, beginning `Taskless CLI:` +- **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-16-refresh-global-trust-entry/tasks.md b/openspec/changes/archive/2026-10-16-refresh-global-trust-entry/tasks.md new file mode 100644 index 00000000..1a364654 --- /dev/null +++ b/openspec/changes/archive/2026-10-16-refresh-global-trust-entry/tasks.md @@ -0,0 +1,20 @@ +## 1. Entry text and stale detection + +- [x] 1.1 Widen `GLOBAL_TRUST_ENTRY` in `packages/cli/src/install/global-trust.ts` with the shell-plumbing sentence; verify `global-trust.test.ts` asserts the sentence and that the text still names no version +- [x] 1.2 Add the `stale` state: `inspect()` returns it for the first labelled string whose text differs, with the edited text replacing that index; `syncGlobalTrust({ write: true })` writes for `absent` and `stale`; verify tests for current/stale/unlabelled, in-place replacement preserving order and other entries, and duplicates beyond the first left alone +- [x] 1.3 `describeGlobalTrust()` returns a line for `stale` entries marked as a replacement, and `globalTrustWarnings()` warns for `stale` saying the existing entry is out of date; verify with unit tests + +## 2. Wizard and init + +- [x] 2.1 Summary shows `REPLACE (global):` for a stale entry and counts it as a settings edit; the wizard writes on confirm for `absent` or `stale`; verify `wizard-integration.test.ts` covers replace-on-confirm and byte-identical on decline +- [x] 2.2 `init` reports `stale` in `--json` and warns, never writing; update `packages/cli/src/agent/init.md` state list; verify `init-no-interactive.test.ts` asserts the stale state and an unchanged user file + +## 3. Skill body + +- [x] 3.1 Add the standalone-command rule to `skills/taskless/SKILL.md`, and rewrite the classifier fallback for a present entry, the rerun-once-on-its-own exception, and replacing an older labelled entry, with the new entry text inline; verify `installed-documentation.test.ts` (entry text parity) and the skill body cap test pass +- [x] 3.2 Add skill-body tests for the new scenarios (standalone rule, present entry not asked for again, combined command rerun once); verify they pass + +## 4. Release + +- [x] 4.1 Add a `patch` changeset describing the entry refresh and the standalone rule; run `pnpm typecheck`, `pnpm lint`, and `pnpm --filter @taskless/cli test`, all passing +- [x] 4.2 Run the pre-archive scenario check from CLAUDE.md, then archive the change on the PR diff --git a/openspec/specs/agent-global-trust/spec.md b/openspec/specs/agent-global-trust/spec.md index e369ee6c..4a2217f0 100644 --- a/openspec/specs/agent-global-trust/spec.md +++ b/openspec/specs/agent-global-trust/spec.md @@ -30,6 +30,8 @@ A harness SHALL be considered only when `init` installs the skill for it, by the 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 @@ -41,25 +43,45 @@ The entry SHALL begin with the label `Taskless CLI:`. It SHALL name `@taskless/c - **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 treat the entry as present when the harness's list holds any string beginning with `Taskless CLI:`, whatever follows the label. A present entry SHALL never be rewritten, replaced, or removed. +`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 treat the entry as present -- **AND** SHALL NOT edit the file +- **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, 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 labelled `ALLOW (global):` that names the packages, says a call is covered only at an exact version, and names 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 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, 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. +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 does not find the entry SHALL offer it again. +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 @@ -67,6 +89,12 @@ Nothing about the entry SHALL be recorded in the project manifest, and a decline - **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 @@ -75,12 +103,12 @@ Nothing about the entry SHALL be recorded in the project manifest, and a decline #### Scenario: The global entry alone is still asked -- **WHEN** the project's permission files already hold the installed rule and the entry is absent -- **THEN** the summary SHALL show only the `ALLOW (global):` line and ask the same confirmation +- **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 +- **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 @@ -96,7 +124,7 @@ Nothing about the entry SHALL be recorded in the project manifest, and a decline #### Scenario: Non-interactive never writes it -- **WHEN** `init --no-interactive` runs for `.claude/` and the entry is absent +- **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 @@ -106,7 +134,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 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. 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, 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 `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 @@ -114,6 +142,11 @@ When a harness in the table is installed for and its entry is absent after `init - **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 @@ -128,3 +161,8 @@ When a harness in the table is installed for and its entry is absent after `init - **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/specs/skill-taskless/spec.md b/openspec/specs/skill-taskless/spec.md index a041d972..7e1c442a 100644 --- a/openspec/specs/skill-taskless/spec.md +++ b/openspec/specs/skill-taskless/spec.md @@ -76,7 +76,7 @@ The consolidated skill body SHALL NOT contain step-by-step instructions for any 7. States that a recipe is resolved when it is fetched and is not reusable across tasks: the agent SHALL fetch the recipe at the start of every Taskless task, including a topic it already fetched earlier in the same session, and SHALL NOT act on an earlier copy. 8. Names the CLI through the `%(TASKLESS_CLI)s` placeholder everywhere it spells an invocation, never as the literal `npx @taskless/cli`. The install renders the placeholder to the build's invocation, so a nightly or path-form build serves a skill that names itself. -The body SHALL be no more than 150 lines of markdown. The cap guards against unbounded growth of a body loaded on every Taskless task; it SHALL NOT be met by compressing guidance the router needs. When the body needs more room to route correctly, the cap is raised instead. (Earlier caps of 60 and then 80 lines forced exactly that compression.) +The body SHALL be no more than 200 lines of markdown. The cap guards against unbounded growth of a body loaded on every Taskless task; it SHALL NOT be met by compressing guidance the router needs. When the body needs more room to route correctly, the cap is raised instead. (Earlier caps of 60 and then 80 lines forced exactly that compression; 150 was reached exactly by the standalone-command rule and the present-entry fallback.) #### Scenario: Skill body warns against improvising @@ -121,7 +121,7 @@ The body SHALL be no more than 150 lines of markdown. The cap guards against unb #### Scenario: Skill body length cap - **WHEN** the skill body is measured -- **THEN** it SHALL be no more than 150 lines of markdown +- **THEN** it SHALL be no more than 200 lines of markdown ### Requirement: Skill routes authoring requests through the routing front door @@ -164,9 +164,9 @@ The skill body SHALL instruct the agent to run `%(TASKLESS_CLI)s agent ok` once 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. +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, and give the user the global trust entry, inline, with the file and key to add it under (in Claude Code, top-level `autoMode.allow` in `~/.claude/settings.json`, after `"$defaults"`). The agent SHALL NOT write that entry itself. It SHALL also offer 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 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. 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. @@ -198,6 +198,16 @@ The global trust entry and these instructions SHALL be written in the skill body - **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 @@ -212,3 +222,18 @@ The skill body SHALL route a request to use, try, or set up Taskless that names - **WHEN** the skill body is read from source - **THEN** it SHALL route a request that names no task to `agent onboard` - **AND** SHALL instruct the agent not to offer a menu of topics first + +### Requirement: Taskless commands run on their own + +The skill body SHALL instruct the agent to run every Taskless command as a command line of its own that begins with the pinned invocation, with nothing before or after it: no variable assignment, `cd`, `mkdir`, `&&`, `;`, pipe, or output redirect. The body SHALL say why: a permission rule and a classifier exception both recognise the pinned call, and a line that starts with anything else, or hands the call's output to another command, is judged as something else. The agent SHALL read the call's output from its result, and SHALL write a file a recipe asks for with its own file tools rather than through a redirect. + +#### Scenario: Skill body requires standalone commands + +- **WHEN** the skill body is read from source +- **THEN** it SHALL instruct the agent to run each Taskless command on its own line, beginning with `%(TASKLESS_CLI)s` +- **AND** SHALL name variable assignments, `cd`, `mkdir`, `&&`, pipes, and redirects as things not to combine with it + +#### Scenario: Files go through the agent's file tools + +- **WHEN** the skill body is read from source +- **THEN** it SHALL instruct the agent to write a file a recipe asks for with its file tools, not by redirecting a Taskless command's output diff --git a/packages/cli/src/agent/init.md b/packages/cli/src/agent/init.md index 3493e0bc..62287da1 100644 --- a/packages/cli/src/agent/init.md +++ b/packages/cli/src/agent/init.md @@ -88,18 +88,21 @@ 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`, 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`, tell the user that their - agent may refuse Taskless commands the project rule allows, and that - the printed warning names the entry to add themselves, or that - running `%(TASKLESS_CLI)s` with no arguments, in their own - terminal, offers to add it (`init` itself never asks). + `present`, `absent`, `stale` (a `Taskless CLI:` entry 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 + printed warning names the entry to add, or to replace the old one + with, themselves, or that running `%(TASKLESS_CLI)s` with no + arguments, in their own terminal, offers to do it (`init` itself + never asks). Without `--json`, the same facts print as prose: a per-target summary, each permission edit as `Edited ` with `ALLOW:` and `REMOVE:` - lines (or `SKIPPED:` with the reason), a warning for each missing - global trust entry, then a trailer naming the directories that changed and, after a + lines (or `SKIPPED:` with the reason), a warning for each missing or + stale global trust entry, then a trailer naming the directories that + changed and, after a version move, pointing at `update`, then a notice naming each stale pin and the version to move it to. diff --git a/packages/cli/src/install/global-trust.ts b/packages/cli/src/install/global-trust.ts index 3f6648d9..b85e42eb 100644 --- a/packages/cli/src/install/global-trust.ts +++ b/packages/cli/src/install/global-trust.ts @@ -18,9 +18,10 @@ import { parseJson, set, valueAt } from "./permissions"; * only moment a human is there to make it is the interactive install. * * Unlike a project rule, the entry is personal state: it is never recorded in - * the project manifest, never names a version, is written at most once, and - * is never removed. A present entry is found by its label, so rewording the - * text later never touches an entry a user already has. + * the project manifest, never names a version, and is never removed. It is + * found by its label, and the label is ours: an entry under it whose text is + * 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. * * 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 @@ -45,8 +46,11 @@ export const GLOBAL_TRUST_ENTRY = "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. " + // ast-grep-ignore: no-unrouted-cli-invocation - "@taskless/cli@1.2.3). Dist-tags such as @latest or @nightly, version " + - "ranges, and unversioned calls are not covered."; + "@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."; /** Where a harness's user settings live, from the environment. */ export interface GlobalTrustEnvironment { @@ -96,10 +100,11 @@ export interface GlobalTrustOutcome { /** The list the entry belongs in, dotted: `autoMode.allow`. */ key: string; /** - * `present`: already there, untouched. `absent`: missing, nothing written. - * `written`: added by this run. `skipped`: left alone, see `skipped`. + * `present`: the current text, untouched. `absent`: missing, nothing + * written. `stale`: labelled with other text, nothing written. `written`: + * added or replaced by this run. `skipped`: left alone, see `skipped`. */ - state: "present" | "absent" | "written" | "skipped"; + state: "present" | "absent" | "stale" | "written" | "skipped"; /** Why the file was left alone, or `null` when it was not. */ skipped: string | null; } @@ -107,7 +112,7 @@ export interface GlobalTrustOutcome { export interface SyncGlobalTrustOptions { /** Tool directories the skill is installed into. */ selected: readonly string[]; - /** `true` adds a missing entry; `false` only reports. */ + /** `true` adds a missing entry or replaces a stale one; `false` only reports. */ write: boolean; /** Defaults to the real home directory and environment. */ environment?: GlobalTrustEnvironment; @@ -123,7 +128,7 @@ async function readText(path: string): Promise { type Inspection = | { kind: "present" } - | { kind: "absent"; text: string } + | { kind: "absent" | "stale"; text: string } | { kind: "skip"; reason: string }; /** Whether the file holds the entry, and the edited text when it does not. */ @@ -151,8 +156,18 @@ function inspect(trust: GlobalTrust, current = "{}\n"): Inspection { if (!Array.isArray(list)) { return { kind: "skip", reason: `${trust.key.join(".")} is not a list` }; } - if (list.some((item) => typeof item === "string" && item.startsWith(LABEL))) { - return { kind: "present" }; + const index = list.findIndex( + (item) => typeof item === "string" && item.startsWith(LABEL) + ); + if (index !== -1) { + if (list[index] === GLOBAL_TRUST_ENTRY) return { kind: "present" }; + // Replaced where it sits, so the user's ordering survives. A second + // labelled string is left alone: deleting from a user's list is more + // than the one line the summary shows. + return { + kind: "stale", + text: set(current, [...trust.key, index], GLOBAL_TRUST_ENTRY), + }; } // An existing list without `$defaults` is the user replacing the defaults on // purpose, so it is appended to as it stands. @@ -165,8 +180,8 @@ function inspect(trust: GlobalTrust, current = "{}\n"): Inspection { } /** - * Report, and with `write` add, the global trust entry for each selected - * harness that has one. A present entry is never rewritten. + * Report, and with `write` add or refresh, the global trust entry for each + * selected harness that has one. A present entry is never rewritten. */ export async function syncGlobalTrust( options: SyncGlobalTrustOptions @@ -196,23 +211,29 @@ export async function syncGlobalTrust( return outcomes; } +/** Whether the wizard has an edit to offer for this outcome. */ +export function needsWrite(outcome: GlobalTrustOutcome): boolean { + return outcome.state === "absent" || outcome.state === "stale"; +} + /** `~/…` for a path under the home directory, so the summary reads short. */ function display(file: string, home: string): string { return file.startsWith(`${home}/`) ? `~${file.slice(home.length)}` : file; } /** - * The proposed edit for each missing entry, for the wizard's summary, beside - * the project's own lines: what it allows, and where it goes. `global` marks - * it as the one edit outside the project. + * The proposed edit for each missing or stale entry, for the wizard's + * summary, beside the project's own lines: what it allows, and where it goes. + * `replace` marks a stale entry, which is swapped rather than added. */ export function describeGlobalTrust( outcomes: readonly GlobalTrustOutcome[], home: string = homedir() -): { what: string; where: string }[] { +): { replace: boolean; what: string; where: string }[] { return outcomes - .filter((outcome) => outcome.state === "absent") + .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", where: `in ${display(outcome.file, home)} (${outcome.key})`, @@ -220,8 +241,9 @@ export function describeGlobalTrust( } /** - * A warning for each harness still missing its entry. `installer` is the - * command a person runs, with no arguments, to get the wizard that offers it. + * A warning for each harness still missing its entry, or holding a stale one. + * `installer` is the command a person runs, with no arguments, to get the + * wizard that offers it. */ export function globalTrustWarnings( outcomes: readonly GlobalTrustOutcome[], @@ -229,16 +251,21 @@ export function globalTrustWarnings( home: string = homedir() ): string[] { return outcomes - .filter( - (outcome) => outcome.state === "absent" || outcome.state === "skipped" - ) - .map( - (outcome) => + .filter((outcome) => needsWrite(outcome) || outcome.state === "skipped") + .map((outcome) => { + const list = `the top-level \`${outcome.key}\` list in ${display(outcome.file, home)}`; + 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` + : `To trust Taskless, add this entry to ${list}` + + (outcome.state === "skipped" + ? ` (left alone: ${outcome.skipped})` + : "") + + `, after "$defaults", or ${wizard}, which offers to add it`; + return ( `${outcome.harness} may refuse Taskless commands that the project rule allows: its safety classifier overrules project allow rules. ` + - `To trust Taskless, add this entry to the top-level \`${outcome.key}\` list in ${display(outcome.file, home)}` + - (outcome.state === "skipped" - ? ` (left alone: ${outcome.skipped})` - : "") + - `, after "$defaults", or run \`${installer}\` with no arguments in a terminal, which offers to add it:\n ${GLOBAL_TRUST_ENTRY}` - ); + `${fix}:\n ${GLOBAL_TRUST_ENTRY}` + ); + }); } diff --git a/packages/cli/src/wizard/index.ts b/packages/cli/src/wizard/index.ts index cf353dd1..b7024e53 100644 --- a/packages/cli/src/wizard/index.ts +++ b/packages/cli/src/wizard/index.ts @@ -14,6 +14,7 @@ import { } from "../install/install"; import { globalTrustWarnings, + needsWrite, syncGlobalTrust, type GlobalTrustEnvironment, } from "../install/global-trust"; @@ -116,7 +117,7 @@ export async function runWizard( await applyInstallPlan(options.cwd, plan, { cliVersion }); await syncPermissions(options.cwd, { ...permissionOptions, write: true }); // Confirmed with the project edits, in the summary's one question. - if (globalTrustPreview.some((outcome) => outcome.state === "absent")) { + if (globalTrustPreview.some((outcome) => needsWrite(outcome))) { await syncGlobalTrust({ ...globalTrustOptions, write: true }); } // Read again rather than assumed, so a skipped file, and a write that did diff --git a/packages/cli/src/wizard/steps/summary.ts b/packages/cli/src/wizard/steps/summary.ts index 66a340d6..2b829733 100644 --- a/packages/cli/src/wizard/steps/summary.ts +++ b/packages/cli/src/wizard/steps/summary.ts @@ -8,6 +8,7 @@ import "../../util/color"; import { describeGlobalTrust, + needsWrite, type GlobalTrustOutcome, } from "../../install/global-trust"; import { commandOfRule, type PermissionEdit } from "../../install/permissions"; @@ -61,8 +62,12 @@ export async function renderSummaryAndConfirm( ...(globalTrust === undefined ? [] : describeGlobalTrust(globalTrust.outcomes, globalTrust.home).map( - (line) => - ({ label: "ALLOW (global):", tone: "allow", ...line }) as const + ({ replace, what, where }): SettingsLine => ({ + label: replace ? "REPLACE (global):" : "ALLOW (global):", + tone: "allow", + what, + where, + }) )), ]); if (settingsLines.length > 0) { @@ -75,8 +80,7 @@ export async function renderSummaryAndConfirm( // nothing is being removed. const editsSettings = permissions.some((edit) => edit.skipped === null) || - (globalTrust?.outcomes.some((outcome) => outcome.state === "absent") ?? - false); + (globalTrust?.outcomes.some((outcome) => needsWrite(outcome)) ?? false); if (!diff.hasRemovals && !editsSettings) { return true; } diff --git a/packages/cli/test/global-trust.test.ts b/packages/cli/test/global-trust.test.ts index f5e73550..f3820354 100644 --- a/packages/cli/test/global-trust.test.ts +++ b/packages/cli/test/global-trust.test.ts @@ -52,6 +52,15 @@ describe("the entry", () => { expect(GLOBAL_TRUST_ENTRY).toContain("@latest"); expect(GLOBAL_TRUST_ENTRY).toContain("not covered"); }); + + 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" + ); + expect(GLOBAL_TRUST_ENTRY).toContain( + "every other command on the line is judged on its own" + ); + }); }); describe("syncGlobalTrust", () => { @@ -107,19 +116,74 @@ describe("syncGlobalTrust", () => { }); }); - it("treats a reworded entry as present and leaves the file alone", async () => { + it("leaves the current entry alone", async () => { + const original = JSON.stringify({ + autoMode: { allow: ["$defaults", GLOBAL_TRUST_ENTRY] }, + }); + await put(original); + const outcomes = await sync(true); + + expect(outcomes[0]?.state).toBe("present"); + expect(await read()).toBe(original); + }); + + it("reports a reworded entry as stale without writing", async () => { const original = JSON.stringify({ autoMode: { allow: ["$defaults", "Taskless CLI: allowed with a pinned version"], }, }); await put(original); - const outcomes = await sync(true); + const outcomes = await sync(false); - expect(outcomes[0]?.state).toBe("present"); + expect(outcomes[0]?.state).toBe("stale"); expect(await read()).toBe(original); }); + it("replaces a stale entry where it sits", async () => { + await put( + JSON.stringify({ + autoMode: { + allow: [ + "$defaults", + "Taskless CLI: allowed with a pinned version", + "Internal Registry: ours", + ], + }, + }) + ); + const outcomes = await sync(true); + + expect(outcomes[0]?.state).toBe("written"); + expect(JSON.parse(await read())).toEqual({ + autoMode: { + allow: ["$defaults", GLOBAL_TRUST_ENTRY, "Internal Registry: ours"], + }, + }); + }); + + it("replaces only the first labelled entry", async () => { + await put( + JSON.stringify({ + autoMode: { allow: ["Taskless CLI: one", "Taskless CLI: two"] }, + }) + ); + await sync(true); + + expect(JSON.parse(await read())).toEqual({ + autoMode: { allow: [GLOBAL_TRUST_ENTRY, "Taskless CLI: two"] }, + }); + }); + + it("never reads an unlabelled entry as ours", async () => { + await put( + JSON.stringify({ autoMode: { allow: ["Internal Registry: Taskless"] } }) + ); + const outcomes = await sync(false); + + expect(outcomes[0]?.state).toBe("absent"); + }); + it("only reports when not writing", async () => { const original = "{}\n"; await put(original); @@ -183,6 +247,7 @@ describe("rendering", () => { const outcomes = await sync(false); expect(describeGlobalTrust(outcomes, home)).toEqual([ { + replace: false, what: "@taskless/cli or @taskless/cli-nightly at an exact version", where: "in ~/.claude/settings.json (autoMode.allow)", }, @@ -206,6 +271,19 @@ describe("rendering", () => { expect(warning).toContain(GLOBAL_TRUST_ENTRY); }); + it("describes a stale entry as a replacement, and warns that it is out of date", async () => { + await put(JSON.stringify({ autoMode: { allow: ["Taskless CLI: old"] } })); + const outcomes = await sync(false); + + expect(describeGlobalTrust(outcomes, home)).toEqual([ + expect.objectContaining({ replace: true }), + ]); + const [warning] = globalTrustWarnings(outcomes, "npx @taskless/cli", home); + expect(warning).toContain("is out of date"); + expect(warning).toContain("which offers to replace it"); + expect(warning).toContain(GLOBAL_TRUST_ENTRY); + }); + it("is silent once the entry is present", async () => { await sync(true); const outcomes = await sync(false); diff --git a/packages/cli/test/init-no-interactive.test.ts b/packages/cli/test/init-no-interactive.test.ts index ffa7f495..ea6de78b 100644 --- a/packages/cli/test/init-no-interactive.test.ts +++ b/packages/cli/test/init-no-interactive.test.ts @@ -12,6 +12,7 @@ import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { LATEST_SCHEMA_VERSION } from "../src/filesystem/migrate"; +import { GLOBAL_TRUST_ENTRY } from "../src/install/global-trust"; import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); @@ -529,7 +530,7 @@ describe("taskless init (the batch install)", () => { await writeFile( userSettings(), JSON.stringify({ - autoMode: { allow: ["$defaults", "Taskless CLI: x"] }, + autoMode: { allow: ["$defaults", GLOBAL_TRUST_ENTRY] }, }) ); const { stdout } = await run(); @@ -537,6 +538,23 @@ describe("taskless init (the batch install)", () => { expect(stdout).not.toContain("autoMode.allow"); }); + it("warns about a stale entry, reports it, and never replaces it", async () => { + await mkdir(join(home, ".claude"), { recursive: true }); + const original = JSON.stringify({ + autoMode: { allow: ["$defaults", "Taskless CLI: x"] }, + }); + await writeFile(userSettings(), original); + const { stdout, stderr } = await run("--json"); + const envelope = JSON.parse(stdout) as { + globalTrust: Array<{ state: string }>; + }; + + expect(envelope.globalTrust[0]?.state).toBe("stale"); + expect(stderr).toContain("is out of date"); + expect(stderr).toContain(GLOBAL_TRUST_ENTRY); + expect(await readFile(userSettings(), "utf8")).toBe(original); + }); + it("reports it on the --json envelope, with the warning on stderr", async () => { const { stdout, stderr } = await run("--json"); const envelope = JSON.parse(stdout) as { diff --git a/packages/cli/test/installed-documentation.test.ts b/packages/cli/test/installed-documentation.test.ts index f62c70c6..41f273da 100644 --- a/packages/cli/test/installed-documentation.test.ts +++ b/packages/cli/test/installed-documentation.test.ts @@ -228,6 +228,42 @@ describe("this repository's own installed Taskless docs", () => { expect(prose).toContain("For any other block, offer three ways forward"); }); + it("runs each Taskless command on its own line", async () => { + // An onboarding agent ran `S= && mkdir -p $S && npx …` and + // auto mode refused it as Untrusted Code Integration, entry or no entry: + // a line that starts with something else is judged as something else. + const skill = await readFile( + resolve(repositoryRoot, "skills", "taskless", "SKILL.md"), + "utf8" + ); + const prose = skill.replaceAll(/\s+/g, " "); + expect(prose).toContain( + "Run every Taskless command on a line of its own that begins with `%(TASKLESS_CLI)s`" + ); + expect(prose).toContain( + "no variable assignment, `cd`, `mkdir`, `&&`, `;`, pipe, or redirect" + ); + expect(prose).toContain( + "write any file a recipe asks for with your file tools" + ); + expect(prose).toContain("rerun it once on its own first"); + }); + + it("does not ask for an entry the user already has", async () => { + // The same agent then told a user with the entry to go and add it. + const skill = await readFile( + resolve(repositoryRoot, "skills", "taskless", "SKILL.md"), + "utf8" + ); + const prose = skill.replaceAll(/\s+/g, " "); + expect(prose).toContain( + "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:`" + ); + }); + 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. @@ -238,8 +274,8 @@ describe("this repository's own installed Taskless docs", () => { expect(skill).toContain(`\n${GLOBAL_TRUST_ENTRY}\n`); }); - it("keeps the skill body within its 150-line cap", async () => { - // `skill-taskless` caps the body at 150 lines of markdown. The cap guards + it("keeps the skill body within its 200-line cap", async () => { + // `skill-taskless` caps the body at 200 lines of markdown. The cap guards // against unbounded growth, not against guidance the router needs: when // the body needs the room, raise the cap rather than compress the text. // Counted the way a reader would, every line after the closing `---`, @@ -249,7 +285,7 @@ describe("this repository's own installed Taskless docs", () => { "utf8" ); const body = skill.replace(/^---\n[\s\S]*?\n---\n/, "").trimEnd(); - expect(body.split("\n").length).toBeLessThanOrEqual(150); + expect(body.split("\n").length).toBeLessThanOrEqual(200); }); it("keeps the skill's trigger text off the old layout too", async () => { diff --git a/packages/cli/test/wizard-integration.test.ts b/packages/cli/test/wizard-integration.test.ts index 4da731c4..f0cede69 100644 --- a/packages/cli/test/wizard-integration.test.ts +++ b/packages/cli/test/wizard-integration.test.ts @@ -310,9 +310,11 @@ describe("runWizard end-to-end", () => { }); it("does not offer an entry that is already there", async () => { + const { GLOBAL_TRUST_ENTRY } = + await import("../src/install/global-trust"); await mkdir(dirname(userSettings()), { recursive: true }); const original = JSON.stringify({ - autoMode: { allow: ["$defaults", "Taskless CLI: mine"] }, + autoMode: { allow: ["$defaults", GLOBAL_TRUST_ENTRY] }, }); await writeFile(userSettings(), original); clackResponses.locations = [".claude"]; @@ -321,7 +323,60 @@ describe("runWizard end-to-end", () => { const { runWizard } = await import("../src/wizard"); await runWizard({ cwd, globalTrustEnvironment }); - expect(loggedMessages.join("\n")).not.toContain("ALLOW (global)"); + expect(loggedMessages.join("\n")).not.toContain("(global)"); + expect(await readFile(userSettings(), "utf8")).toBe(original); + }); + + it("replaces a stale entry where it sits once confirmed", async () => { + const { GLOBAL_TRUST_ENTRY } = + await import("../src/install/global-trust"); + await mkdir(dirname(userSettings()), { recursive: true }); + await writeFile( + userSettings(), + JSON.stringify({ + autoMode: { + allow: [ + "$defaults", + "Taskless CLI: mine", + "Internal Registry: ours", + ], + }, + }) + ); + clackResponses.locations = [".claude"]; + clackResponses.summary = true; + + const { runWizard } = await import("../src/wizard"); + await runWizard({ cwd, globalTrustEnvironment }); + + expect(loggedMessages.join("\n")).toContain( + "REPLACE (global): @taskless/cli or @taskless/cli-nightly at an exact version\n" + + " in ~/.claude/settings.json (autoMode.allow)" + ); + expect(confirmMessages).toEqual(["Make these changes to your settings?"]); + const settings = JSON.parse(await readFile(userSettings(), "utf8")) as { + autoMode: { allow: string[] }; + }; + expect(settings.autoMode.allow).toEqual([ + "$defaults", + GLOBAL_TRUST_ENTRY, + "Internal Registry: ours", + ]); + expect(warnedMessages).toEqual([]); + }); + + it("declining leaves a stale entry byte-identical", async () => { + await mkdir(dirname(userSettings()), { recursive: true }); + const original = JSON.stringify({ + autoMode: { allow: ["$defaults", "Taskless CLI: mine"] }, + }); + await writeFile(userSettings(), original); + clackResponses.locations = [".claude"]; + clackResponses.summary = false; + + const { runWizard } = await import("../src/wizard"); + await runWizard({ cwd, globalTrustEnvironment }); + expect(await readFile(userSettings(), "utf8")).toBe(original); }); }); diff --git a/skills/taskless/SKILL.md b/skills/taskless/SKILL.md index 26bbefd1..d0277bee 100644 --- a/skills/taskless/SKILL.md +++ b/skills/taskless/SKILL.md @@ -42,26 +42,38 @@ request: no survey of the install, and no menu of what Taskless can do. `init` writes an allow rule for this exact invocation into the permission settings of each harness it installs for, so the preflight usually passes. +Run every Taskless command on a line of its own that begins with +`%(TASKLESS_CLI)s`, with nothing around it: no variable assignment, `cd`, +`mkdir`, `&&`, `;`, pipe, or redirect. Permission rules and classifier +exceptions recognise the pinned call; a line that starts with anything else, +or hands its output to another command, is judged as something else. Read the +output from the call's result, and write any file a recipe asks for with your +file tools. + If the harness or the user blocks it, or blocks any later Taskless command, stop before doing any Taskless work. Do not retry the command or try to get -around the block. Send one message that names the exact command you ran and +around the block, with one exception: if the refused line combined the call +with anything else, rerun it once on its own first. Send one message that names the exact command you ran and says it downloads and runs a package from npm. The rest of that message depends on what refused it. If your own safety classifier refused it, not a missing permission (in Claude Code, a denial "by the Claude Code auto mode classifier"), no allow rule can -clear it: the classifier overrules project rules. Give the user this entry, to -add themselves to the top-level `autoMode.allow` list in -`~/.claude/settings.json`, after `"$defaults"`: +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:`: ```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). Dist-tags such as @latest or @nightly, version ranges, and unversioned calls are not covered. +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. ``` Never write that entry yourself: an agent widening its own classifier's trust is exactly what the classifier refuses. Running `%(TASKLESS_CLI)s` with no -arguments, in their own terminal, also offers to add it. Until then, they can approve the exact command in chat, -which the classifier weighs but may still refuse, or leave auto mode. +arguments, in their own terminal, also offers to add or refresh it. Until +then, they can approve the exact command in chat, which the classifier weighs +but may still refuse, or leave auto mode. For any other block, offer three ways forward: