From 2e1b5ce59414314419c4935e077937b5c1a98520 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 15 Oct 2026 13:49:54 -0700 Subject: [PATCH] feat(skill): one approval, then onboarding's one question; remove /tskl On a blocked preflight the agent sends one message with three remedies, including adding the allow rule itself and handing the user the change when its harness refuses the edit. It no longer asks what the user wants while blocked or after the preflight passes, and a request that names no task goes straight to onboarding. The tskl slash command is removed: init stops installing it, and an upgrade removes the copies an older install recorded. --- .changeset/agent-ok-preflight.md | 2 +- .changeset/remove-tskl-command.md | 7 + .claude-plugin/plugin.json | 3 +- .gitignore | 1 - .taskless/README.md | 1 - README.md | 14 +- commands/tskl/tskl.md | 59 --- dotagents.md | 5 +- .../proposal.md | 44 ++ .../specs/cli-agent/spec.md | 34 ++ .../specs/cli-init/spec.md | 409 ++++++++++++++++++ .../specs/infrastructure/spec.md | 7 + .../specs/skill-taskless/spec.md | 112 +++++ .../specs/skills/spec.md | 41 ++ .../tasks.md | 17 + openspec/specs/cli-agent/spec.md | 6 +- openspec/specs/cli-init/spec.md | 126 +++--- openspec/specs/infrastructure/spec.md | 11 - openspec/specs/skill-taskless/spec.md | 62 ++- openspec/specs/skills/spec.md | 26 +- packages/cli/README.md | 21 +- packages/cli/src/agent/init.md | 23 +- packages/cli/src/commands/init.ts | 25 +- .../src/filesystem/migrations/0001-init.ts | 1 - packages/cli/src/install/canonical.ts | 70 +-- packages/cli/src/install/install.ts | 140 ++---- packages/cli/src/wizard/index.ts | 9 +- packages/cli/test/apply-install-plan.test.ts | 141 +++--- packages/cli/test/canonical-store.test.ts | 82 +--- packages/cli/test/cli.test.ts | 7 +- packages/cli/test/install.test.ts | 19 +- .../cli/test/installed-documentation.test.ts | 95 ++-- packages/cli/test/migrated-envelope.test.ts | 3 +- .../test/nightly/skill-version-stamp.test.ts | 7 +- .../stub-description-invocation.test.ts | 14 +- .../nightly/stub-recovery-invocation.test.ts | 10 +- .../test/stub-description-invocation.test.ts | 15 +- skills/taskless/SKILL.md | 26 +- 38 files changed, 992 insertions(+), 703 deletions(-) create mode 100644 .changeset/remove-tskl-command.md delete mode 100644 commands/tskl/tskl.md create mode 100644 openspec/changes/archive/2026-10-15-one-approval-one-question/proposal.md create mode 100644 openspec/changes/archive/2026-10-15-one-approval-one-question/specs/cli-agent/spec.md create mode 100644 openspec/changes/archive/2026-10-15-one-approval-one-question/specs/cli-init/spec.md create mode 100644 openspec/changes/archive/2026-10-15-one-approval-one-question/specs/infrastructure/spec.md create mode 100644 openspec/changes/archive/2026-10-15-one-approval-one-question/specs/skill-taskless/spec.md create mode 100644 openspec/changes/archive/2026-10-15-one-approval-one-question/specs/skills/spec.md create mode 100644 openspec/changes/archive/2026-10-15-one-approval-one-question/tasks.md diff --git a/.changeset/agent-ok-preflight.md b/.changeset/agent-ok-preflight.md index 9dd0c5a7..aab6b7c4 100644 --- a/.changeset/agent-ok-preflight.md +++ b/.changeset/agent-ok-preflight.md @@ -2,4 +2,4 @@ "@taskless/cli": patch --- -New `taskless agent ok` topic: it prints one line confirming the CLI can run, through the invocation it was launched with, and does nothing else. The Taskless skill and the `/tskl` command now run it once per session before any other Taskless command, so a harness that blocks download-and-run commands (Claude Code's auto mode, for one) stops the agent before it starts work rather than partway through. On a blocked command the agent now names the exact command to the user, offers three ways forward (an allow rule for exactly that invocation, approval through the agent's permissions command such as `/permissions`, or approving every Taskless command by hand in chat), and never proposes an allow rule that wildcards the package name or version. +New `taskless agent ok` topic: it prints one line confirming the CLI can run, through the invocation it was launched with, and does nothing else. The Taskless skill now runs it once per session before any other Taskless command, so a harness that blocks download-and-run commands (Claude Code's auto mode, for one) stops the agent before it starts work rather than partway through. On a blocked command the agent names the exact command in one message and offers three ways forward: it adds an allow rule for exactly that invocation (handing the user the change when the harness blocks the agent's own edit), the user approves it through their agent's permissions command such as `/permissions`, or the user approves every Taskless command by hand in chat. It never proposes an allow rule that wildcards the package name or version, and never asks what the user wants while blocked. A request that names no task, such as "let's use taskless", goes straight to onboarding, so its one question is the only one the user sees. diff --git a/.changeset/remove-tskl-command.md b/.changeset/remove-tskl-command.md new file mode 100644 index 00000000..6284dd2d --- /dev/null +++ b/.changeset/remove-tskl-command.md @@ -0,0 +1,7 @@ +--- +"@taskless/cli": minor +--- + +The `/tskl` slash command is removed. `taskless init` no longer installs it, and upgrading removes the copies an earlier install wrote, from `.taskless/commands/` and from each tool's `commands/tskl/`. Other files in a tool's `commands/` directory are left alone. + +What to do: ask your agent in plain language instead, such as "set up taskless" or "add taskless to CI". The Taskless skill picks that up the same way `/tskl` did. The `init --json` envelope drops `commandsInstalled` and each target's `writtenCommands`, and keeps `removedCommands`, which is where the removal shows up. diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 0c10eec1..4a554592 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -7,6 +7,5 @@ }, "homepage": "https://taskless.io", "repository": "https://github.com/taskless/cli", - "license": "MIT", - "commands": "./commands/tskl/" + "license": "MIT" } diff --git a/.gitignore b/.gitignore index 32e445c2..8867981a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,6 @@ node_modules/ /.taskless/skills/ /.taskless/commands/ /.claude/skills/taskless/ -/.claude/commands/tskl/ # Vale platform-package tarballs (npm pack output from vale-prepare.cjs) .vale-dist/ diff --git a/.taskless/README.md b/.taskless/README.md index 413b2291..e7d6fd5d 100644 --- a/.taskless/README.md +++ b/.taskless/README.md @@ -19,7 +19,6 @@ npx @taskless/cli@latest check - `taskless.json` - Version manifest / migration state - `.env.local.json` - Local authentication credentials (git-ignored) - `skills/` - Canonical Taskless skill content; tool directories hold thin stubs that delegate here (managed by Taskless) -- `commands/` - Canonical Taskless command content (managed by Taskless) Every rule is one directory, `rules///`, holding everything that defines it. Its test cases sit inside it as diff --git a/README.md b/README.md index 0b4a1f68..a8425a15 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,6 @@ Includes a CLI that works with agentic systems (and humans too) at @taskless/cli ``` skills/ taskless/SKILL.md # Single consolidated router skill -commands/ - tskl/tskl.md # Single /tskl router command packages/ cli/ # @taskless/cli, recipes live in cli/src/agent/ scripts/ @@ -20,13 +18,7 @@ scripts/ ## Skill -Starting in v0.7, Taskless ships a **single consolidated skill** (`taskless`) plus a single `/tskl` slash command. The skill body is a small router; per-task instructions live behind `npx @taskless/cli agent ` and are fetched on demand. - -| Skill | Command | Description | -| ---------- | ------------- | ------------------------------------------------------ | -| `taskless` | `/tskl ` | Router for any Taskless action (create rule, improve, | -| | | delete, check, auth, CI). Fetches the canonical recipe | -| | | for the user's intent and follows it. | +Taskless ships a **single consolidated skill** (`taskless`). The skill body is a small router; per-task instructions live behind `npx @taskless/cli agent ` and are fetched on demand. It handles any Taskless action (create rule, improve, delete, check, auth, CI) by fetching the canonical recipe for the user's intent and following it. Available `taskless agent` topics: `route`, `create-sg-rule`, `create-vale-rule`, `create-runtime-rule`, `create-remote-rule`, `improve-rule`, `delete-rule`, `check`, `auth`, `ci`, `info`, `init`, `update`. Run `taskless agent` with no topic for the index. Append `--anonymous` for the local-only flow on improve. @@ -129,10 +121,10 @@ In v0.7+, new agent-facing instructions are added as **recipes**, not skills. To 1. Create `packages/cli/src/agent/.txt` following the canonical template (Goal / Preconditions / Steps / Input schema / Errors / See Also). 2. Use `{{CLI_VERSION}}` and `{{INPUT_SCHEMA}}` placeholders for runtime interpolation. 3. For topics with a substantively different local-only flow, add `.anonymous.txt`. The `agent` command's variant lookup is automatic. -4. Update the topic table in `skills/taskless/SKILL.md` and `commands/tskl/tskl.md` so agents can discover the new topic. +4. Update the topic table in `skills/taskless/SKILL.md` so agents can discover the new topic. ### Distribution channels -- **`taskless init`**: CLI installs the consolidated skill to `.claude/skills/taskless/` and the command to `.claude/commands/tskl/` +- **`taskless init`**: CLI installs the consolidated skill to `.taskless/skills/taskless/` and a reference stub to `.claude/skills/taskless/` - **Claude Code Plugin Marketplace**: `.claude-plugin/marketplace.json` and `plugin.json` - **Vercel Skills CLI**: `npx skills add` discovers skills from `skills/` directory diff --git a/commands/tskl/tskl.md b/commands/tskl/tskl.md deleted file mode 100644 index 4bed0390..00000000 --- a/commands/tskl/tskl.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -name: "Taskless" -description: Run any Taskless action — create/improve/delete a rule, run check, manage auth, or wire CI. Routes via `agent ` to fetch the canonical recipe and follow it. -category: Taskless -argument-hint: -tags: - - taskless -metadata: - author: taskless - commandName: tskl ---- - -# Taskless - -The user invoked Taskless via `/tskl` with: $ARGUMENTS - -If `$ARGUMENTS` is empty or ambiguous, ask the user what they want to do -with Taskless before proceeding. - -Otherwise, follow the same flow as the `taskless` skill: - -0. Preflight, once per session, before any other Taskless command: run - `%(TASKLESS_CLI)s agent ok` and proceed once it prints its confirmation - line. If the harness or the user blocks it, or blocks any later Taskless - command, stop and do not try to get around the block. Tell the user the - exact command and that it downloads and runs a package from npm, then - offer three ways forward and wait for their choice: you add an allow rule - for exactly the invocation `%(TASKLESS_CLI)s` to the agent's permission - settings, they approve it through their agent's permissions command - (such as `/permissions` in Claude Code), or they approve every Taskless - command manually, here in chat, as you run it. Never propose a rule wider - than that invocation, such as a wildcard over the package name or its - version. -1. Identify the topic from `$ARGUMENTS` using the table below. -2. Fetch the canonical recipe with `%(TASKLESS_CLI)s agent ` (or - `%(TASKLESS_CLI)s agent --anonymous` if the user is offline or - explicitly asked for anonymous mode). -3. Follow the recipe step-by-step. The recipe is canonical for the - currently-installed CLI version; do not improvise from prior knowledge. -4. Fetch again next time. A recipe is resolved when it is fetched, from the - installed version, the auth state, and the project layout at that moment. - A copy fetched earlier in this session is not a substitute, even for the - same topic. - -## Topics - -| User wants | Topic | -| -------------------------- | ------------------------------------- | -| Update Taskless skills | run `%(TASKLESS_CLI)s update` | -| Create a new rule | `%(TASKLESS_CLI)s agent route` | -| Improve an existing rule | `%(TASKLESS_CLI)s agent improve-rule` | -| Delete a rule | `%(TASKLESS_CLI)s agent delete-rule` | -| Check code against rules | `%(TASKLESS_CLI)s agent check` | -| Log in, log out, or status | `%(TASKLESS_CLI)s agent auth` | -| Wire into CI | `%(TASKLESS_CLI)s agent ci` | -| Make or delete a report | `%(TASKLESS_CLI)s agent report` | - -If unsure, run `%(TASKLESS_CLI)s agent` (no args) for the topic -disambiguation table. diff --git a/dotagents.md b/dotagents.md index 53eb2452..59e54e38 100644 --- a/dotagents.md +++ b/dotagents.md @@ -51,9 +51,8 @@ repository carries a `.claude-plugin/plugin.json`, and `add` checks a source for plugins first: when it finds one, the source is plugin-only. So that command adds a plugin named `taskless` with `path = "."`, which vendors the entire repository (including `packages/`, `example/`, and the lockfile) into -`.agents/plugins/taskless/`. What it ships is the Taskless skill and the `tskl` -command, which is the right way to get those, and it is not a route to -`iterate-pr`. +`.agents/plugins/taskless/`. What it ships is the Taskless skill, which is the +right way to get that, and it is not a route to `iterate-pr`. To take the development skills, declare them with an explicit `path`. One skill: diff --git a/openspec/changes/archive/2026-10-15-one-approval-one-question/proposal.md b/openspec/changes/archive/2026-10-15-one-approval-one-question/proposal.md new file mode 100644 index 00000000..b50dabe2 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-one-approval-one-question/proposal.md @@ -0,0 +1,44 @@ +## Why + +A real session of "let's use taskless" took four user turns to reach onboarding's one question: + +1. Auto mode blocked `agent ok`. The agent offered three remedies, then also asked what the user wanted Taskless to do. +2. The user picked "I add an allow rule". Auto mode refused the agent's write to `.claude/settings.local.json`, so the agent handed the user a one-line command that made the change. That worked. +3. Once the preflight passed, the agent surveyed the install and offered a menu of topics. +4. The user said "set it up", and only then did onboarding ask its question. + +The goal is one approval, if one is needed, and then onboarding's single question. The two "what next?" questions repeat a request the user already made. The fallback in step 2 is worth keeping: the agent knows what its harness accepts, and the skill cannot. + +The `tskl` slash command carried a second copy of the same routing and preflight guidance, kept in step with the skill by hand, and it is overhead now that a plain request reaches the skill. + +## What Changes + +- On a blocked command, the agent sends one message naming the command and offers three remedies: it adds the exact-invocation allow rule, and hands the user the change if the harness blocks its edit; the user approves it through their agent's permissions command (in Claude Code, `/permissions` with `Bash(:*)`); or the user approves every Taskless command manually in chat. +- The blocked message does not ask what the user wants. Their request stands, and the agent carries it out once unblocked. +- Once `agent ok` passes, the agent goes straight to the request, without surveying the install or offering a menu. +- A request that names no task ("use", "try", or "set up" Taskless) routes to `agent onboard`, whose question is the only one asked. +- **BREAKING** The `tskl` slash command is removed. `init` stops installing it. An upgrade removes the command an older install recorded in the manifest, from the canonical store and from each tool directory, along with any empty `tskl/` directory. A symlinked `tskl/` directory is unlinked rather than deleted through. The `init --json` envelope drops `commandsInstalled` and `writtenCommands`. + +## Delivery shape + +**Single PR.** The skill edits and the command removal touch the same guidance, and the removal is only coherent with the skill carrying all of it. Most of the diff is deletion: the command source, its stub builder, and tests for both. + +## Capabilities + +### Modified Capabilities + +- `skill-taskless`: the blocked-preflight remedies, routing for a request that names no task, and the command mapping removed. +- `cli-init`: no command is installed, and an upgrade removes the retired one. +- `cli-agent`: the invocation requirement no longer names the command file. +- `skills`: no `commandName` metadata, no commands directory, and the plugin declares no commands. +- `infrastructure`: the hand-authored command requirement is removed. + +## Impact + +- `skills/taskless/SKILL.md`: preflight and routing. The body stays within its 150-line cap. +- `commands/tskl/` deleted. `.claude-plugin/plugin.json` no longer declares commands. +- `packages/cli/src/install/`, `commands/init.ts`, `wizard/index.ts`: command writing removed; removal of recorded commands kept. +- `agent/init.md` (topic v6), the `0001` README template, and `.taskless/README.md`. +- READMEs and `dotagents.md`. +- Tests across the install suite. +- Changesets: `agent-ok-preflight` corrected in place (still unreleased); `remove-tskl-command` added as `minor`, since users of `/tskl` must change how they ask. diff --git a/openspec/changes/archive/2026-10-15-one-approval-one-question/specs/cli-agent/spec.md b/openspec/changes/archive/2026-10-15-one-approval-one-question/specs/cli-agent/spec.md new file mode 100644 index 00000000..b934a7cd --- /dev/null +++ b/openspec/changes/archive/2026-10-15-one-approval-one-question/specs/cli-agent/spec.md @@ -0,0 +1,34 @@ +## MODIFIED Requirements + +### Requirement: Recipes name the CLI by its full invocation + +Every instruction in shipped agent-facing content that tells a reader to run the Taskless CLI SHALL name the CLI by the invocation the build resolves for the reader, followed by the subcommand and its arguments — never by a hand-written one. Two mechanisms deliver that, because the content travels by two different paths: + +- Recipe sources under `packages/cli/src/agent/` SHALL express the CLI as `%(TASKLESS_CLI)s`, which the recipe renderer substitutes at render time. +- `skills/taskless/SKILL.md` is not rendered through the recipe renderer, so it SHALL carry the canonical `npx @taskless/cli` string, which `applyCliInvocation` rewrites to the build target's invocation when the content is emitted. + +Content SHALL NOT name the CLI as a bare `taskless` binary, because it is not installed on `PATH` for the overwhelming majority of readers. A recipe source SHALL NOT hardcode a launcher-and-package string such as `npx @taskless/cli` either, because that is the fact `%(TASKLESS_CLI)s` exists to hold in one place — the string is canonical only in the file above, where it is the rewrite anchor rather than a hardcoding. Prose that mentions the product, a config filename, or a directory (`taskless.config`, `.taskless/`) is unaffected — the requirement is about executable instructions. + +An automated check SHALL fail when a bare `` `taskless ` `` invocation, or a hardcoded `npx @taskless/cli`, appears in a recipe source, so the normalization cannot silently regress as recipes are edited. The check SHALL read the subcommand names from the CLI's own registry rather than restating them, so a newly added subcommand is covered without a second edit. It does not extend to `skills/taskless/SKILL.md`, whose rewrite anchor is the very string it flags and which quote invocations as example user utterances. + +#### Scenario: A recipe instructs the reader to run a subcommand + +- **WHEN** a recipe tells the reader to fetch another topic +- **THEN** the source SHALL read `%(TASKLESS_CLI)s agent ` rather than `taskless agent ` or `npx @taskless/cli agent ` + +#### Scenario: A regression is reintroduced + +- **WHEN** a recipe source is edited to contain a bare `` `taskless check` ``-style invocation +- **THEN** the automated check SHALL fail and name the offending file and line + +#### Scenario: Non-prod builds rewrite every invocation, not some of them + +- **WHEN** a `nightly` or `self` build renders any recipe +- **THEN** every CLI invocation in the rendered text SHALL name that build's own invocation, with no occurrence left naming the released `@taskless/cli` + +#### Scenario: Cross-reference checking survives the normalization + +- **WHEN** the check that recipe cross-references cite only topics that resolve is run +- **THEN** it SHALL operate on rendered recipe text, where the invocation is a stable literal, rather than on source text where it is a placeholder +- **AND** it SHALL anchor on the invocation the running build actually renders, so the check does not pass vacuously under a `nightly`, `dev`, or `self` build whose rendered invocation names neither `taskless` nor `@taskless/cli` +- **AND** it SHALL fail when it finds no cross-reference at all, since an empty result is otherwise indistinguishable from every reference resolving diff --git a/openspec/changes/archive/2026-10-15-one-approval-one-question/specs/cli-init/spec.md b/openspec/changes/archive/2026-10-15-one-approval-one-question/specs/cli-init/spec.md new file mode 100644 index 00000000..fbafd4af --- /dev/null +++ b/openspec/changes/archive/2026-10-15-one-approval-one-question/specs/cli-init/spec.md @@ -0,0 +1,409 @@ +## ADDED Requirements + +### Requirement: An upgrade removes the retired tskl command + +Taskless no longer ships a slash command. The install manifest SHALL keep recording a `commands` list per target, so that an install over an older one removes any `tskl.md` command the prior manifest recorded, from the canonical `.taskless/` store and from each tool directory. Each removal SHALL be confined to the entries the prior manifest recorded for that target. + +After removing a recorded command, the CLI SHALL remove that target's `commands/tskl/` directory when it is empty, and for the canonical `.taskless/` target SHALL also remove `.taskless/commands/` when it is empty. A tool directory's own `commands/` directory and any other file in it SHALL be left in place. + +When a target's `commands/tskl/` is a symlink, the CLI SHALL remove the symlink itself and SHALL NOT delete any file through it. + +The `taskless init --json` envelope SHALL report each removed command in that target's `removedCommands`. + +#### Scenario: A recorded command is removed from the canonical store and a tool directory + +- **WHEN** the prior manifest records `tskl.md` under both `.taskless` and `.claude`, and `taskless init` runs +- **THEN** `.taskless/commands/tskl/tskl.md` and `.claude/commands/tskl/tskl.md` SHALL be deleted +- **AND** the new manifest SHALL record no commands for either target + +#### Scenario: Empty directories go, a tool's other commands stay + +- **WHEN** the recorded command is removed and `.claude/commands/` also holds a file Taskless did not write +- **THEN** `.claude/commands/tskl/` and `.taskless/commands/` SHALL no longer exist +- **AND** the other file in `.claude/commands/` SHALL be left untouched + +#### Scenario: A symlinked command directory is unlinked, not deleted through + +- **WHEN** `.claude/commands/tskl` is a symlink to a directory elsewhere and the prior manifest records `tskl.md` under `.claude` +- **THEN** the symlink SHALL be removed +- **AND** the file it pointed at SHALL be left untouched + +#### Scenario: The JSON envelope reports what went + +- **WHEN** `taskless init --json` removes a recorded command +- **THEN** the affected target's `removedCommands` SHALL list `tskl.md` + +## MODIFIED Requirements + +### Requirement: Init subcommand installs skills into a repository + +The CLI SHALL support a `taskless init` subcommand that installs the consolidated `taskless` skill into the current working directory's detected tool locations, and upgrades an existing install. `init` SHALL always run the batch install: every detected tool location (or `.agents/` fallback when none detected), without prompting and without an auth step, whether or not a TTY is attached. It is the path an agent, a script, and a CI job take, and the path a person takes to upgrade without answering prompts. The interactive wizard is reached by running the CLI with no subcommand in a TTY, never by `init`. + +The `--no-interactive` flag is no longer defined. An invocation that still passes it SHALL behave exactly as `init` without it, so an existing script keeps working. + +There is exactly one mandatory skill in v0.7.0 (`taskless`) and zero optional skills. The wizard's optional-skill selection step SHALL be removed. + +The `--anonymous` flag is accepted on `init` as a no-op (init does not call the Taskless API directly). + +#### Scenario: Running taskless init installs the consolidated skill + +- **WHEN** a user runs `taskless init`, in a terminal or under a pipe +- **THEN** the CLI SHALL install the single `taskless` skill to every detected tool location without prompting for tools or auth +- **AND** SHALL NOT launch the wizard + +#### Scenario: A legacy --no-interactive flag is harmless + +- **WHEN** a script runs `taskless init --no-interactive` +- **THEN** the CLI SHALL behave exactly as for `taskless init` + +#### Scenario: Init removes obsolete v0.6 skill files + +- **WHEN** a user with v0.6 installed (10 per-task skills written) runs `taskless init` +- **THEN** the install plumbing SHALL read the previous install state from `.taskless/taskless.json` +- **AND** SHALL delete the 10 obsolete skill files and 6 obsolete command files +- **AND** SHALL write the new `taskless` skill and no command +- **AND** SHALL update `.taskless/taskless.json` install state to reflect the new layout + +#### Scenario: Init reports cleanup transparently + +- **WHEN** init removes obsolete files +- **THEN** the install summary output SHALL include "removed N obsolete skills" and "removed M obsolete commands" +- **AND** SHALL list the obsolete skill names so the user understands what changed + +### Requirement: Claude Code detection signals + +Claude Code SHALL be detected when any of the following exist in the project root: + +- `.claude/` directory +- `CLAUDE.md` file + +When `.claude/` is selected, a reference skill stub SHALL be installed to `.claude/skills//SKILL.md`. No command SHALL be installed. + +#### Scenario: Claude Code detected by .claude directory + +- **WHEN** `.claude/` exists as a directory in the project root +- **THEN** Claude Code SHALL be detected +- **AND** a reference skill stub SHALL be installed to `.claude/skills/` + +#### Scenario: Claude Code detected by CLAUDE.md file + +- **WHEN** `CLAUDE.md` exists as a file in the project root +- **AND** `.claude/` directory does not exist +- **THEN** Claude Code SHALL be detected +- **AND** a reference skill stub SHALL be installed to `.claude/skills/` + +### Requirement: Cursor detection signals + +Cursor SHALL be detected when any of the following exist in the project root: + +- `.cursor/` directory +- `.cursorrules` file + +When `.cursor/` is selected, a reference skill stub SHALL be installed to `.cursor/skills//SKILL.md`. No command SHALL be installed. + +#### Scenario: Cursor detected by .cursor directory + +- **WHEN** `.cursor/` exists as a directory in the project root +- **THEN** Cursor SHALL be detected + +#### Scenario: Cursor detected by .cursorrules file + +- **WHEN** `.cursorrules` exists as a file in the project root +- **THEN** Cursor SHALL be detected + +#### Scenario: Selecting Cursor writes a skill stub and a command stub + +- **WHEN** `.cursor/` is selected and `taskless init` runs +- **THEN** a reference skill stub SHALL be written to `.cursor/skills/taskless/SKILL.md` +- **AND** no command file SHALL be written under `.cursor/` + +### Requirement: Skills are installed as Agent Skills spec SKILL.md files + +The CLI SHALL install skill content using a canonical-store-plus-stub model rather than writing a full copy per detected tool. The full skill content SHALL be written exactly once to the canonical `.taskless/skills//SKILL.md`. Each selected tool directory SHALL receive its own reference stub as defined by the reference-stub requirement. Skill names SHALL be installed verbatim from the embedded source. No additional namespace prefixing SHALL be applied at install time. + +The skill source SHALL name the CLI through the `%(TASKLESS_CLI)s` placeholder, the same token the recipes use, and SHALL NOT contain the literal `npx @taskless/cli` in its body. The canonical write renders the placeholder to the build's own invocation (`npx @taskless/cli` for a release build, the pinned nightly or path-form invocation otherwise). Rendering is an exact-token substitution, not a search for the literal invocation in prose: a literal is whitespace-sensitive, so a wrapped line or a doubled space silently escaped the rewrite and reached a nightly install naming the release package. + +#### Scenario: Canonical skill content matches source + +- **WHEN** a skill is installed +- **THEN** the canonical `.taskless/skills//SKILL.md` content SHALL be identical to the embedded source from `skills/` with every `%(TASKLESS_CLI)s` rendered to the build's invocation, which for a release build is `npx @taskless/cli` +- **AND** no frontmatter fields SHALL be modified at install time + +#### Scenario: The sources carry the placeholder, not the literal + +- **WHEN** the skill source under `skills/` is read +- **THEN** its body SHALL contain `%(TASKLESS_CLI)s` wherever it names the CLI +- **AND** SHALL NOT contain the literal `npx @taskless/cli` +- **AND** the rendered canonical content SHALL contain no unrendered `%(` token + +#### Scenario: Selected tool directory receives a stub, not a full copy + +- **WHEN** the CLI installs the `taskless` skill and any tool directory is selected +- **THEN** that directory's skill location SHALL contain a reference stub +- **AND** SHALL NOT contain a full copy of the canonical skill content + +### Requirement: Skills are bundled into the CLI at build time + +The CLI build SHALL embed all skill file content from `skills/` into the compiled bundle using Vite's `import.meta.glob` with raw file imports. No runtime file reads or network fetches SHALL be used to access skill content. The build SHALL embed no command content. + +#### Scenario: Embedded skills are available at runtime + +- **WHEN** the CLI runs `taskless init` +- **THEN** it SHALL access skill content from the embedded bundle without reading the filesystem or making network requests + +#### Scenario: Embedded commands are available at runtime + +- **WHEN** the CLI runs `taskless init` for Claude Code +- **THEN** it SHALL have no command content to write, embedded or otherwise + +#### Scenario: Build includes all skills from source directory + +- **WHEN** `pnpm build` is run in `packages/cli/` +- **THEN** every `SKILL.md` file under `skills/` SHALL be embedded in the output bundle + +#### Scenario: Build includes all commands from source directory + +- **WHEN** `pnpm build` is run in `packages/cli/` +- **THEN** no command file SHALL be embedded in the output bundle + +### Requirement: Wizard prompts the user to choose install locations + +The wizard's location step SHALL be presented as a tool-selection step: "which tools do you want to enable Taskless for?". It SHALL offer a fixed catalog of rows: `Claude Code` (`.claude/`), `Codex` (`.agents/`), `Cursor` (`.cursor/`), `OpenCode` (`.opencode/`), and `Agent Skills` (`.agents/`). A row names a tool the user scans for, so more than one row MAY offer the same directory: Codex reads `.agents/`, and a user who arrived from Codex SHALL NOT have to know that before recognising their harness in the list. The generic `Agent Skills` row SHALL remain, since it serves harnesses this catalog does not enumerate. + +Every list of directories derived from the catalog SHALL name each directory at most once, however many rows offer it. The pre-checked set SHALL be the union of (a) every directory recorded as a target in the install manifest (`install.targets`) that matches an offered row, and (b) every detected tool's install directory, deduplicated by directory. When the manifest records no targets AND no tools are detected, `.agents/` SHALL be pre-checked as the first-run default. The canonical `.taskless/` store SHALL NOT appear as a selectable entry and SHALL NOT be pre-checked — it is always written and is never a manifest tool-directory target. + +Each offered entry SHALL carry an origin hint: `installed` when the entry's directory is recorded in the install manifest; otherwise `detected` when the entry's tool is detected on the filesystem; otherwise `not detected`. The generic `Agent Skills` row MAY instead carry a hint describing it as the generic agent-skills location; a row naming a specific harness SHALL NOT carry that hint. The `installed` hint SHALL take precedence over `detected` when both apply. + +Unchecking a pre-checked, manifest-recorded entry SHALL cause the resulting install plan to omit that target, so the existing manifest-diff removal path removes Taskless's reference stubs from that directory. The at-least-one-tool selection rule is unchanged: the wizard SHALL require at least one checked entry. + +Each selected directory SHALL produce exactly one `reference` stub target, even when several offered rows name it; the resulting install plan always contains the single `taskless` skill and no command. A directory offered by more than one row SHALL be reported in the install summary under a single label. The function that maps detected tools and manifest targets to multiselect choices SHALL be pure — it SHALL receive both the detected tools and the manifest target list as arguments and SHALL perform no filesystem access — so the mapping is unit-testable. + +#### Scenario: Codex is named in the tool list + +- **WHEN** the wizard renders the tool-selection multiselect +- **THEN** a `Codex` entry SHALL be offered for `.agents/` +- **AND** a separate generic `Agent Skills` entry SHALL also be offered for `.agents/` + +#### Scenario: A directory offered by two rows is pre-checked once + +- **WHEN** the wizard reaches the tool-selection step and Codex is detected +- **THEN** `.agents/` SHALL appear exactly once in the pre-checked set + +#### Scenario: A directory offered by two rows installs once + +- **WHEN** the user's selection includes `.agents/`, whichever of its rows was checked +- **THEN** the install plan SHALL contain exactly one `.agents/` target +- **AND** the `.agents/` skill stub SHALL be written once + +#### Scenario: Detected tools are pre-checked + +- **WHEN** the wizard reaches the tool-selection step and `.claude/` is detected +- **THEN** `.claude/` SHALL be pre-checked in the multiselect +- **AND** `.claude/` SHALL carry the `detected` hint when it is not recorded in the install manifest + +#### Scenario: Manifest-recorded locations are pre-checked + +- **WHEN** the wizard reaches the tool-selection step and the install manifest records `.agents/` as a target +- **THEN** `.agents/` SHALL be pre-checked in the multiselect +- **AND** `.agents/` SHALL carry the `installed` hint + +#### Scenario: Installed hint takes precedence over detected + +- **WHEN** the wizard reaches the tool-selection step and `.claude/` is both detected on the filesystem and recorded in the install manifest +- **THEN** `.claude/` SHALL be pre-checked +- **AND** `.claude/` SHALL carry the `installed` hint, not the `detected` hint + +#### Scenario: Unchecking an installed location removes its stubs + +- **WHEN** the install manifest records `.claude/` and `.agents/` as targets and the user unchecks `.claude/` while leaving `.agents/` checked +- **THEN** the resulting install plan SHALL omit the `.claude/` target +- **AND** the wizard summary SHALL list the `.claude/` reference stubs as removals + +#### Scenario: Agents is the default when nothing is detected or installed + +- **WHEN** the wizard reaches the tool-selection step, no tools are detected, and the install manifest records no tool-directory targets +- **THEN** `.agents/` SHALL be pre-checked + +#### Scenario: Canonical store is not a selectable entry + +- **WHEN** the wizard renders the tool-selection multiselect +- **THEN** `.taskless/` SHALL NOT appear as a selectable option +- **AND** `.taskless/` SHALL NOT be pre-checked even though the manifest records it as a target + +### Requirement: Init prints a post-install onboarding trailer + +After a successful install (both wizard and `--no-interactive` paths), `taskless init` SHALL print a single one-line trailer pointing the user at the new onboarding flow. The trailer SHALL be printed AFTER the install summary (the lines that report what was written and any obsolete files removed) and SHALL be the final line of output before the process exits. The trailer SHALL NOT be gated on the value of `install.onboarded` in the manifest — it is informational, printed every successful install. The trailer's wording SHALL be the same for every install plan: every installed target receives the Taskless skill, so the trailer SHALL tell the user to ask their AI tool to onboard with Taskless, naming the Taskless skill, and SHALL NOT mention `/tskl onboard` or a terminal `taskless onboard` command. The trailer SHALL be suppressed when `taskless init` exits non-zero (cancelled wizard, install failure, etc.) or when the install was a no-op (no targets selected, no files to write). + +#### Scenario: Wizard install points at the skill only + +- **WHEN** a user runs `taskless init` in an interactive terminal and the wizard completes successfully +- **THEN** the final line of output SHALL be a single-line trailer telling the user to ask their AI tool to onboard with the Taskless skill +- **AND** the trailer SHALL NOT mention `/tskl onboard` or `taskless onboard` + +#### Scenario: Non-interactive install points at the skill only + +- **WHEN** a user runs `taskless init --no-interactive`, whatever tools are detected +- **THEN** the final line of output SHALL be the same skill-only trailer +- **AND** SHALL NOT mention `/tskl onboard` or `taskless onboard` + +#### Scenario: Cancelled wizard suppresses the trailer + +- **WHEN** a user cancels the wizard (Ctrl-C or equivalent) +- **THEN** the trailer SHALL NOT be printed + +#### Scenario: Failed install suppresses the trailer + +- **WHEN** `taskless init` exits non-zero due to an install failure +- **THEN** the trailer SHALL NOT be printed + +#### Scenario: Trailer is printed regardless of onboarded state + +- **WHEN** a user re-runs `taskless init` and `install.onboarded` is already `true` +- **THEN** the trailer SHALL still be printed +- **AND** the trailer wording SHALL be the same skill-only trailer + +### Requirement: Skill and command content is installed once to the canonical .taskless store + +The CLI SHALL write skill content exactly once per install, to a canonical store inside Taskless's owned `.taskless/` namespace: skill content to `.taskless/skills//SKILL.md`. The canonical write SHALL occur on every install that contains at least one skill, regardless of which tools are selected. No command content SHALL be written. + +The `.taskless/` canonical store SHALL NOT be a tool install target: no tool's detection, install destination, or cleanup logic SHALL point at `.taskless/skills/` or `.taskless/commands/`. This guarantees that no install target can ever delete the canonical content. The canonical target's own removal of a command its prior manifest recorded, as the retired-command requirement describes, is not a tool target's cleanup. + +#### Scenario: Canonical content is written once + +- **WHEN** `taskless init` runs and the install plan contains the `taskless` skill +- **THEN** the CLI SHALL write the full skill content to `.taskless/skills/taskless/SKILL.md` +- **AND** SHALL NOT write anything under `.taskless/commands/` + +#### Scenario: Canonical write happens regardless of selected tools + +- **WHEN** `taskless init` runs with any combination of tool directories selected, including none +- **THEN** the canonical `.taskless/` store SHALL be written + +#### Scenario: No tool target points at the canonical store + +- **WHEN** the install plan is constructed and applied +- **THEN** no tool target's install or cleanup operation SHALL write to or delete `.taskless/skills/` or `.taskless/commands/` + +### Requirement: Selected tool directories receive reference stubs + +For every selected tool directory, the CLI SHALL write a **reference stub** rather than a full copy. Each selected directory receives its own stub — `.claude/`, `.cursor/`, `.opencode/`, and `.agents/` are peer targets, and no directory is special-cased or routed onto another. A stub SHALL be an ordinary file (never a symlink). A skill stub SHALL contain valid YAML frontmatter with `name` and `description` copied from the canonical skill so the tool can discover and trigger it, plus a `metadata` block carrying `type: shim` (which marks the file as a reference stub) and the canonical `version` (carried for reference and kept in lockstep with the canonical content). Its body SHALL instruct the agent to read the canonical file (`.taskless/skills//SKILL.md`) and follow it, and SHALL NOT duplicate the canonical content inline. Every stub SHALL point directly at a canonical file, never at another stub. + +The CLI SHALL NOT create symlinks for any tool. + +The per-directory stub layout is: + +- `.claude/skills//SKILL.md` — Claude Code. +- `.cursor/skills//SKILL.md` — Cursor. +- `.opencode/skills//SKILL.md` — OpenCode. +- `.agents/skills//SKILL.md` — generic Agent Skills location, including Codex. + +No directory receives a command stub. + +#### Scenario: Skill stub has valid frontmatter and a delegating body + +- **WHEN** the CLI writes a skill stub for a selected directory +- **THEN** the stub SHALL be a regular file with frontmatter `name` and `description` matching the canonical skill +- **AND** the stub frontmatter SHALL include `metadata.type: shim` +- **AND** its body SHALL delegate to `.taskless/skills//SKILL.md` without inlining the canonical instructions + +#### Scenario: Each selected directory gets its own stub + +- **WHEN** `taskless init` runs with `.cursor/` and `.opencode/` both selected +- **THEN** a skill stub SHALL be written to `.cursor/skills/taskless/SKILL.md` +- **AND** a skill stub SHALL be written to `.opencode/skills/taskless/SKILL.md` + +#### Scenario: No symlinks are created + +- **WHEN** any `taskless init` or `taskless update` run completes +- **THEN** no skill file or directory written by the CLI SHALL be a symlink + +### Requirement: Update rewrites canonical content and preserves reference stubs + +`taskless update` SHALL rewrite the canonical `.taskless/skills/` content from the embedded bundle. A canonical file whose bytes already equal what the bundle would write SHALL be left untouched and SHALL NOT be reported as written: the install summary, the `--json` `targets` field, and the upgrade trailer all describe what changed on disk, and a byte-identical rewrite is not a change. For `reference`-mode targets, update SHALL create a stub only if it is missing, and SHALL NOT overwrite an existing stub with full canonical content. Update SHALL re-generate a stub in place only when its frontmatter `name`, `description`, or `metadata.version` has drifted from the canonical content; the stub's delegating body SHALL be preserved. + +Update SHALL NOT delete or `rm -rf` the canonical `.taskless/` store, nor any directory that another target sources content from. Removal logic SHALL operate only on entries recorded in the prior manifest and SHALL respect each entry's `mode`. + +#### Scenario: Update refreshes canonical content + +- **WHEN** `taskless update` runs against an install with a newer bundled skill version +- **THEN** `.taskless/skills/taskless/SKILL.md` SHALL be rewritten with the new content + +#### Scenario: An unchanged canonical file is not reported as written + +- **WHEN** the install runs and `.taskless/skills/taskless/SKILL.md` already holds exactly what the bundle would write +- **THEN** the file SHALL NOT be rewritten +- **AND** the run's report SHALL NOT list it as written for the `.taskless` target + +#### Scenario: Update does not clobber a reference stub + +- **WHEN** `taskless update` runs and `.claude/skills/taskless/SKILL.md` is an existing reference stub +- **THEN** update SHALL NOT replace it with full canonical content +- **AND** the stub SHALL continue to delegate to `.taskless/skills/taskless/SKILL.md` + +#### Scenario: Update never destroys the canonical store + +- **WHEN** `taskless update` processes its targets +- **THEN** it SHALL NOT delete `.taskless/skills/` as part of cleaning up any target +- **AND** the canonical content SHALL remain readable throughout the update + +### Requirement: A reference stub says how to restore a missing canonical file + +A reference stub's body SHALL state what to do when the canonical file it points at is not present, naming a command that restores it. A stub that only delegates leaves a reader who follows it at a dead end, and the two states that produce a missing canonical file (an install whose untracked files never reached this working directory, and a project that ignores the canonical store) are both reached without anyone doing anything wrong. + +The command named SHALL be the one that runs the build which wrote the stub, resolved the same way canonical content resolves it. A stub emitted by a development or nightly build SHALL NOT direct the reader to the released package. + +The command named SHALL install without an interactive terminal, since the reader of a stub is typically an agent in a non-interactive context. + +Stub content SHALL carry nothing that varies per release, so that adding this instruction changes the footprint outside the canonical store exactly once. + +A stub already on disk whose body predates this instruction SHALL be rewritten once by the next install, rather than waiting for its frontmatter to change. Detection of such a stub SHALL NOT depend on the build that wrote it, so that builds with different invocations do not rewrite one another's stubs. + +An install SHALL also reclaim a stub whose recovery command names a build other than the one installing, EXCEPT when the command named is the released, version-free one. The released form resolves for every reader, so leaving it in place is what keeps a released build and a nightly from rewriting one another's stub on every install; anything else names a build that may not be reachable at the moment the reader needs it, and is replaced. + +#### Scenario: A pinned nightly recovery command is reclaimed by a later install + +- **WHEN** an install finds a stub whose recovery command names a version-pinned nightly other than the build installing +- **THEN** the install SHALL rewrite that stub with its own recovery command + +#### Scenario: A released recovery command survives a nightly install + +- **WHEN** a nightly build installs over a stub whose recovery command is the released, version-free one +- **THEN** the install SHALL leave that stub untouched +- **AND** a subsequent install by either build SHALL leave it untouched + +#### Scenario: Stub names the command that restores a missing canonical file + +- **WHEN** the CLI writes a skill stub +- **THEN** its body SHALL say what to do if the canonical file does not exist +- **AND** SHALL name a command that restores it + +#### Scenario: A development build points at itself + +- **WHEN** a `dev` or `self` build writes a stub +- **THEN** the command named SHALL be that build's own invocation +- **AND** SHALL NOT be the released package + +#### Scenario: Stub bytes do not move with the CLI version + +- **WHEN** two installs of different CLI versions write a stub for the same skill +- **THEN** the two stubs SHALL be identical + +#### Scenario: An older stub is rewritten once + +- **WHEN** an install finds a stub whose body carries no recovery instruction +- **THEN** the install SHALL rewrite that stub +- **AND** a subsequent install SHALL leave the rewritten stub untouched + +## REMOVED Requirements + +### Requirement: Claude Code commands are placed from embedded source + +**Reason**: Taskless no longer ships the `tskl` slash command. The skill handles every request the command routed, and the command duplicated the skill's preflight and routing text in a second place that had to be kept in step. + +**Migration**: Ask the agent in plain language, which engages the `taskless` skill. The next `taskless init` removes `.claude/commands/tskl/tskl.md` (see "An upgrade removes the retired tskl command"). + +### Requirement: Cursor commands are placed from embedded source + +**Reason**: Taskless no longer ships the `tskl` slash command; see the Claude Code requirement above. + +**Migration**: Ask the agent in plain language, which engages the `taskless` skill. The next `taskless init` removes `.cursor/commands/tskl/tskl.md`. diff --git a/openspec/changes/archive/2026-10-15-one-approval-one-question/specs/infrastructure/spec.md b/openspec/changes/archive/2026-10-15-one-approval-one-question/specs/infrastructure/spec.md new file mode 100644 index 00000000..62e5a16b --- /dev/null +++ b/openspec/changes/archive/2026-10-15-one-approval-one-question/specs/infrastructure/spec.md @@ -0,0 +1,7 @@ +## REMOVED Requirements + +### Requirement: Slash command files are hand-authored + +**Reason**: Taskless no longer ships a slash command, so there is no command file to author by hand or to generate. + +**Migration**: None. The `taskless` skill is the only agent-facing entry point. diff --git a/openspec/changes/archive/2026-10-15-one-approval-one-question/specs/skill-taskless/spec.md b/openspec/changes/archive/2026-10-15-one-approval-one-question/specs/skill-taskless/spec.md new file mode 100644 index 00000000..febf2038 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-one-approval-one-question/specs/skill-taskless/spec.md @@ -0,0 +1,112 @@ +## 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 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.) + +#### 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 150 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. + +When the preflight is blocked by the harness or the user, the body SHALL instruct the agent to: + +1. Stop before doing any Taskless work, and not attempt to work around the block. +2. Send one message that names the exact command, and says it downloads and runs a package from npm. +3. 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. +4. Never propose an allow rule that widens the invocation, such as a wildcard over the package name or its version. +5. 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. + +#### 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 forbid asking what the user wants Taskless to do in that message + +## ADDED Requirements + +### Requirement: A request that names no task routes to onboarding + +The skill body SHALL route a request to use, try, or set up Taskless that names no task to `%(TASKLESS_CLI)s agent onboard`, without first offering a menu of topics. The onboard recipe's question SHALL be the only question the agent asks before onboarding begins. + +#### Scenario: A generic request goes to onboard + +- **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 + +## REMOVED Requirements + +### Requirement: Skill maps to a single tskl command + +**Reason**: The `tskl` slash command is removed. It duplicated the skill's routing in a second body that had to be kept in step by hand, and a plain request to the agent reaches the same skill. + +**Migration**: Ask the agent in plain language ("set up taskless", "add taskless to CI"); the `taskless` skill triggers on it. An upgrade removes installed `tskl` command files. diff --git a/openspec/changes/archive/2026-10-15-one-approval-one-question/specs/skills/spec.md b/openspec/changes/archive/2026-10-15-one-approval-one-question/specs/skills/spec.md new file mode 100644 index 00000000..008a9e09 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-one-approval-one-question/specs/skills/spec.md @@ -0,0 +1,41 @@ +## MODIFIED Requirements + +### Requirement: Skills use SKILL.md format with YAML frontmatter + +The single skill SHALL be defined at `skills/taskless/SKILL.md` with YAML frontmatter (`name`, `description`, `metadata`) followed by markdown instructions. The `name` field SHALL be exactly `taskless` (no per-task prefix). The `metadata` field SHALL include `author` and `version` keys. The `version` SHALL be used for staleness detection when the skill is installed into target repositories. + +The skill body SHALL begin by instructing the agent that it does NOT have step-by-step instructions for any Taskless action and that recipes must be fetched via `npx @taskless/cli agent ` before proceeding. The body SHALL NOT contain inline step-by-step recipes for any individual task — those live in `packages/cli/src/agent/.md` files served by the `agent` subcommand. + +#### Scenario: Skill directory contains valid SKILL.md + +- **WHEN** the skill is built +- **THEN** `skills/taskless/SKILL.md` SHALL exist +- **AND** SHALL contain YAML frontmatter with `name: taskless` and `description` (up to 1024 chars) +- **AND** the `metadata` field SHALL include `author: taskless` and `version` (string matching CLI package version) +- **AND** the markdown body SHALL contain the router instructions described above (no per-task recipes inline) + +#### Scenario: Skill body delegates to the CLI agent command + +- **WHEN** the skill body is read +- **THEN** it SHALL instruct the agent to fetch the canonical recipe via `npx @taskless/cli agent ` before performing any Taskless action +- **AND** SHALL NOT duplicate recipe content inline + +**Distribution.** + +### Requirement: Plugin manifest declares skills and commands + +The `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` SHALL declare the single consolidated skill and no commands. The plugin version SHALL be `0.7.0` for this release. The plugin description MAY be updated to reflect the consolidation. + +#### Scenario: Plugin manifest reflects consolidated bundle + +- **WHEN** `plugin.json` is read +- **THEN** `version` SHALL be `0.7.0` +- **AND** it SHALL NOT declare `commands` + +## REMOVED Requirements + +### Requirement: Commands directory contains Claude Code command files + +**Reason**: The `tskl` slash command is removed; the `taskless` skill is the only agent-facing entry point. + +**Migration**: Ask the agent in plain language; the skill triggers on it. An upgrade removes installed `tskl` command files. diff --git a/openspec/changes/archive/2026-10-15-one-approval-one-question/tasks.md b/openspec/changes/archive/2026-10-15-one-approval-one-question/tasks.md new file mode 100644 index 00000000..af8a8a31 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-one-approval-one-question/tasks.md @@ -0,0 +1,17 @@ +## 1. One approval, one question + +- [x] 1.1 Rewrite the blocked-preflight guidance in `skills/taskless/SKILL.md`: one message, three remedies with the agent's own fallback kept, no "what next?" +- [x] 1.2 Go straight to the request after `agent ok`, and route a request that names no task to `agent onboard` +- [x] 1.3 Extend the preflight test + +## 2. Remove the `tskl` command + +- [x] 2.1 Delete `commands/tskl/`, the `commandName` frontmatter, and the plugin's `commands` entry +- [x] 2.2 Remove command writing from the installer, `init`, and the wizard; keep the manifest-driven removal, unlink a symlinked `tskl/`, and drop empty directories +- [x] 2.3 Update the `init` recipe, the README template, `.taskless/README.md`, the READMEs, and `dotagents.md` +- [x] 2.4 Update the install tests and add coverage for removing a recorded command + +## 3. Release + +- [x] 3.1 Correct the `agent-ok-preflight` changeset and add `remove-tskl-command` +- [x] 3.2 Run `pnpm typecheck` and `pnpm lint` diff --git a/openspec/specs/cli-agent/spec.md b/openspec/specs/cli-agent/spec.md index 5668a094..674b7a03 100644 --- a/openspec/specs/cli-agent/spec.md +++ b/openspec/specs/cli-agent/spec.md @@ -287,11 +287,11 @@ No embedded recipe SHALL contain the string `taskless help`. Recipes cross-refer Every instruction in shipped agent-facing content that tells a reader to run the Taskless CLI SHALL name the CLI by the invocation the build resolves for the reader, followed by the subcommand and its arguments — never by a hand-written one. Two mechanisms deliver that, because the content travels by two different paths: - Recipe sources under `packages/cli/src/agent/` SHALL express the CLI as `%(TASKLESS_CLI)s`, which the recipe renderer substitutes at render time. -- `skills/taskless/SKILL.md` and `commands/tskl/tskl.md` are not rendered through the recipe renderer, so they SHALL carry the canonical `npx @taskless/cli` string, which `applyCliInvocation` rewrites to the build target's invocation when the content is emitted. +- `skills/taskless/SKILL.md` is not rendered through the recipe renderer, so it SHALL carry the canonical `npx @taskless/cli` string, which `applyCliInvocation` rewrites to the build target's invocation when the content is emitted. -Content SHALL NOT name the CLI as a bare `taskless` binary, because it is not installed on `PATH` for the overwhelming majority of readers. A recipe source SHALL NOT hardcode a launcher-and-package string such as `npx @taskless/cli` either, because that is the fact `%(TASKLESS_CLI)s` exists to hold in one place — the string is canonical only in the two files above, where it is the rewrite anchor rather than a hardcoding. Prose that mentions the product, a config filename, or a directory (`taskless.config`, `.taskless/`) is unaffected — the requirement is about executable instructions. +Content SHALL NOT name the CLI as a bare `taskless` binary, because it is not installed on `PATH` for the overwhelming majority of readers. A recipe source SHALL NOT hardcode a launcher-and-package string such as `npx @taskless/cli` either, because that is the fact `%(TASKLESS_CLI)s` exists to hold in one place — the string is canonical only in the file above, where it is the rewrite anchor rather than a hardcoding. Prose that mentions the product, a config filename, or a directory (`taskless.config`, `.taskless/`) is unaffected — the requirement is about executable instructions. -An automated check SHALL fail when a bare `` `taskless ` `` invocation, or a hardcoded `npx @taskless/cli`, appears in a recipe source, so the normalization cannot silently regress as recipes are edited. The check SHALL read the subcommand names from the CLI's own registry rather than restating them, so a newly added subcommand is covered without a second edit. It does not extend to `skills/taskless/SKILL.md` or `commands/tskl/tskl.md`, whose rewrite anchor is the very string it flags and which quote invocations as example user utterances. +An automated check SHALL fail when a bare `` `taskless ` `` invocation, or a hardcoded `npx @taskless/cli`, appears in a recipe source, so the normalization cannot silently regress as recipes are edited. The check SHALL read the subcommand names from the CLI's own registry rather than restating them, so a newly added subcommand is covered without a second edit. It does not extend to `skills/taskless/SKILL.md`, whose rewrite anchor is the very string it flags and which quote invocations as example user utterances. #### Scenario: A recipe instructs the reader to run a subcommand diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index 82d00396..c64cf1d0 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -29,10 +29,10 @@ The `--anonymous` flag is accepted on `init` as a no-op (init does not call the #### Scenario: Init removes obsolete v0.6 skill files -- **WHEN** a user with v0.6 installed (10 per-task skills written) runs the v0.7.0 `taskless init` +- **WHEN** a user with v0.6 installed (10 per-task skills written) runs `taskless init` - **THEN** the install plumbing SHALL read the previous install state from `.taskless/taskless.json` - **AND** SHALL delete the 10 obsolete skill files and 6 obsolete command files -- **AND** SHALL write the new `taskless` skill and `tskl` command +- **AND** SHALL write the new `taskless` skill and no command - **AND** SHALL update `.taskless/taskless.json` install state to reflect the new layout #### Scenario: Init reports cleanup transparently @@ -104,7 +104,7 @@ Claude Code SHALL be detected when any of the following exist in the project roo - `.claude/` directory - `CLAUDE.md` file -When `.claude/` is selected, a reference skill stub SHALL be installed to `.claude/skills//SKILL.md` and a reference command stub to `.claude/commands/tskl/.md`. +When `.claude/` is selected, a reference skill stub SHALL be installed to `.claude/skills//SKILL.md`. No command SHALL be installed. #### Scenario: Claude Code detected by .claude directory @@ -157,7 +157,7 @@ Cursor SHALL be detected when any of the following exist in the project root: - `.cursor/` directory - `.cursorrules` file -When `.cursor/` is selected, a reference skill stub SHALL be installed to `.cursor/skills//SKILL.md` and a reference command stub to `.cursor/commands/tskl/.md`. +When `.cursor/` is selected, a reference skill stub SHALL be installed to `.cursor/skills//SKILL.md`. No command SHALL be installed. #### Scenario: Cursor detected by .cursor directory @@ -173,7 +173,7 @@ When `.cursor/` is selected, a reference skill stub SHALL be installed to `.curs - **WHEN** `.cursor/` is selected and `taskless init` runs - **THEN** a reference skill stub SHALL be written to `.cursor/skills/taskless/SKILL.md` -- **AND** a reference command stub SHALL be written to `.cursor/commands/tskl/` +- **AND** no command file SHALL be written under `.cursor/` ### Requirement: Codex detection signals @@ -273,7 +273,7 @@ Directory detection signals SHALL use `fs.stat()` and verify `isDirectory()`. Fi The CLI SHALL install skill content using a canonical-store-plus-stub model rather than writing a full copy per detected tool. The full skill content SHALL be written exactly once to the canonical `.taskless/skills//SKILL.md`. Each selected tool directory SHALL receive its own reference stub as defined by the reference-stub requirement. Skill names SHALL be installed verbatim from the embedded source. No additional namespace prefixing SHALL be applied at install time. -The skill and command sources SHALL name the CLI through the `%(TASKLESS_CLI)s` placeholder, the same token the recipes use, and SHALL NOT contain the literal `npx @taskless/cli` in their bodies. The canonical write renders the placeholder to the build's own invocation (`npx @taskless/cli` for a release build, the pinned nightly or path-form invocation otherwise). Rendering is an exact-token substitution, not a search for the literal invocation in prose: a literal is whitespace-sensitive, so a wrapped line or a doubled space silently escaped the rewrite and reached a nightly install naming the release package. +The skill source SHALL name the CLI through the `%(TASKLESS_CLI)s` placeholder, the same token the recipes use, and SHALL NOT contain the literal `npx @taskless/cli` in its body. The canonical write renders the placeholder to the build's own invocation (`npx @taskless/cli` for a release build, the pinned nightly or path-form invocation otherwise). Rendering is an exact-token substitution, not a search for the literal invocation in prose: a literal is whitespace-sensitive, so a wrapped line or a doubled space silently escaped the rewrite and reached a nightly install naming the release package. #### Scenario: Canonical skill content matches source @@ -283,8 +283,8 @@ The skill and command sources SHALL name the CLI through the `%(TASKLESS_CLI)s` #### Scenario: The sources carry the placeholder, not the literal -- **WHEN** the skill and command sources under `skills/` and `commands/` are read -- **THEN** each body SHALL contain `%(TASKLESS_CLI)s` wherever it names the CLI +- **WHEN** the skill source under `skills/` is read +- **THEN** its body SHALL contain `%(TASKLESS_CLI)s` wherever it names the CLI - **AND** SHALL NOT contain the literal `npx @taskless/cli` - **AND** the rendered canonical content SHALL contain no unrendered `%(` token @@ -294,40 +294,9 @@ The skill and command sources SHALL name the CLI through the `%(TASKLESS_CLI)s` - **THEN** that directory's skill location SHALL contain a reference stub - **AND** SHALL NOT contain a full copy of the canonical skill content -### Requirement: Claude Code commands are placed from embedded source - -For Claude Code, the CLI SHALL place a reference command stub at `.claude/commands/tskl/.md`. The stub SHALL delegate to the canonical command at `.taskless/commands/tskl/.md` and SHALL NOT inline the command content. - -#### Scenario: Command stub is placed for Claude Code - -- **WHEN** the CLI installs for Claude Code -- **THEN** it SHALL write a command stub to `.claude/commands/tskl/.md` -- **AND** the stub SHALL delegate to `.taskless/commands/tskl/.md` - -#### Scenario: Command stubs are only placed for tools that support commands - -- **WHEN** the CLI installs for a tool directory that does not support commands (`.opencode/`, `.agents/`) -- **THEN** no command file SHALL be written for that directory - -### Requirement: Cursor commands are placed from embedded source - -For Cursor, the CLI SHALL place a reference command stub at `.cursor/commands/tskl/.md`, mirroring the layout used for Claude Code. The stub SHALL delegate to the canonical command at `.taskless/commands/tskl/.md` and SHALL NOT inline the command content. - -#### Scenario: Command stub is placed for Cursor - -- **WHEN** the CLI installs for Cursor -- **THEN** it SHALL write a command stub to `.cursor/commands/tskl/.md` -- **AND** the stub SHALL delegate to `.taskless/commands/tskl/.md` - -#### Scenario: Cursor receives a skill stub and a command stub - -- **WHEN** `.cursor/` is selected and the install plan is applied -- **THEN** a skill stub SHALL be written to `.cursor/skills/` -- **AND** a command stub SHALL be written to `.cursor/commands/tskl/` - ### Requirement: Skills are bundled into the CLI at build time -The CLI build SHALL embed all skill file content from `skills/` and all command file content from `commands/tskl/` into the compiled bundle using Vite's `import.meta.glob` with raw file imports. No runtime file reads or network fetches SHALL be used to access skill or command content. +The CLI build SHALL embed all skill file content from `skills/` into the compiled bundle using Vite's `import.meta.glob` with raw file imports. No runtime file reads or network fetches SHALL be used to access skill content. The build SHALL embed no command content. #### Scenario: Embedded skills are available at runtime @@ -337,7 +306,7 @@ The CLI build SHALL embed all skill file content from `skills/` and all command #### Scenario: Embedded commands are available at runtime - **WHEN** the CLI runs `taskless init` for Claude Code -- **THEN** it SHALL access command content from the embedded bundle without reading the filesystem +- **THEN** it SHALL have no command content to write, embedded or otherwise #### Scenario: Build includes all skills from source directory @@ -347,7 +316,7 @@ The CLI build SHALL embed all skill file content from `skills/` and all command #### Scenario: Build includes all commands from source directory - **WHEN** `pnpm build` is run in `packages/cli/` -- **THEN** every `.md` file under `commands/tskl/` SHALL be embedded in the output bundle +- **THEN** no command file SHALL be embedded in the output bundle ### Requirement: Init respects the global working directory flag @@ -410,7 +379,7 @@ Each offered entry SHALL carry an origin hint: `installed` when the entry's dire Unchecking a pre-checked, manifest-recorded entry SHALL cause the resulting install plan to omit that target, so the existing manifest-diff removal path removes Taskless's reference stubs from that directory. The at-least-one-tool selection rule is unchanged: the wizard SHALL require at least one checked entry. -Each selected directory SHALL produce exactly one `reference` stub target, even when several offered rows name it; the resulting install plan always contains the single `taskless` skill (and, for `.claude/` and `.cursor/`, the `tskl` command). A directory offered by more than one row SHALL be reported in the install summary under a single label. The function that maps detected tools and manifest targets to multiselect choices SHALL be pure — it SHALL receive both the detected tools and the manifest target list as arguments and SHALL perform no filesystem access — so the mapping is unit-testable. +Each selected directory SHALL produce exactly one `reference` stub target, even when several offered rows name it; the resulting install plan always contains the single `taskless` skill and no command. A directory offered by more than one row SHALL be reported in the install summary under a single label. The function that maps detected tools and manifest targets to multiselect choices SHALL be pure — it SHALL receive both the detected tools and the manifest target list as arguments and SHALL perform no filesystem access — so the mapping is unit-testable. #### Scenario: Codex is named in the tool list @@ -519,11 +488,11 @@ After a successful install (both wizard and `--no-interactive` paths), `taskless - **WHEN** a user runs `taskless init` in an interactive terminal and the wizard completes successfully - **THEN** the final line of output SHALL be a single-line trailer telling the user to ask their AI tool to onboard with the Taskless skill -- **AND** the trailer SHALL NOT mention `/tskl onboard` or `taskless onboard`, whether or not a selected target received the `tskl` slash command +- **AND** the trailer SHALL NOT mention `/tskl onboard` or `taskless onboard` #### Scenario: Non-interactive install points at the skill only -- **WHEN** a user runs `taskless init --no-interactive`, whether or not a command-receiving tool (Claude Code or Cursor) is detected +- **WHEN** a user runs `taskless init --no-interactive`, whatever tools are detected - **THEN** the final line of output SHALL be the same skill-only trailer - **AND** SHALL NOT mention `/tskl onboard` or `taskless onboard` @@ -541,19 +510,19 @@ After a successful install (both wizard and `--no-interactive` paths), `taskless - **WHEN** a user re-runs `taskless init` and `install.onboarded` is already `true` - **THEN** the trailer SHALL still be printed -- **AND** the trailer wording SHALL still adapt to whether commands were installed by the current run +- **AND** the trailer wording SHALL be the same skill-only trailer ### Requirement: Skill and command content is installed once to the canonical .taskless store -The CLI SHALL write skill and command content exactly once per install, to a canonical store inside Taskless's owned `.taskless/` namespace: skill content to `.taskless/skills//SKILL.md` and command content to `.taskless/commands/tskl/.md`. The canonical write SHALL occur on every install that contains at least one skill or command, regardless of which tools are selected. +The CLI SHALL write skill content exactly once per install, to a canonical store inside Taskless's owned `.taskless/` namespace: skill content to `.taskless/skills//SKILL.md`. The canonical write SHALL occur on every install that contains at least one skill, regardless of which tools are selected. No command content SHALL be written. -The `.taskless/` canonical store SHALL NOT be a tool install target: no tool's detection, install destination, or cleanup logic SHALL point at `.taskless/skills/` or `.taskless/commands/`. This guarantees that no install target can ever delete the canonical content. +The `.taskless/` canonical store SHALL NOT be a tool install target: no tool's detection, install destination, or cleanup logic SHALL point at `.taskless/skills/` or `.taskless/commands/`. This guarantees that no install target can ever delete the canonical content. The canonical target's own removal of a command its prior manifest recorded, as the retired-command requirement describes, is not a tool target's cleanup. #### Scenario: Canonical content is written once -- **WHEN** `taskless init` runs and the install plan contains the `taskless` skill and `tskl` command +- **WHEN** `taskless init` runs and the install plan contains the `taskless` skill - **THEN** the CLI SHALL write the full skill content to `.taskless/skills/taskless/SKILL.md` -- **AND** SHALL write the full command content to `.taskless/commands/tskl/tskl.md` +- **AND** SHALL NOT write anything under `.taskless/commands/` #### Scenario: Canonical write happens regardless of selected tools @@ -567,16 +536,18 @@ The `.taskless/` canonical store SHALL NOT be a tool install target: no tool's d ### Requirement: Selected tool directories receive reference stubs -For every selected tool directory, the CLI SHALL write a **reference stub** rather than a full copy. Each selected directory receives its own stub — `.claude/`, `.cursor/`, `.opencode/`, and `.agents/` are peer targets, and no directory is special-cased or routed onto another. A stub SHALL be an ordinary file (never a symlink). A skill stub SHALL contain valid YAML frontmatter with `name` and `description` copied from the canonical skill so the tool can discover and trigger it, plus a `metadata` block carrying `type: shim` (which marks the file as a reference stub) and the canonical `version` (carried for reference and kept in lockstep with the canonical content). Its body SHALL instruct the agent to read the canonical file (`.taskless/skills//SKILL.md` for skills, `.taskless/commands/tskl/.md` for commands) and follow it, and SHALL NOT duplicate the canonical content inline. Every stub SHALL point directly at a canonical file, never at another stub. +For every selected tool directory, the CLI SHALL write a **reference stub** rather than a full copy. Each selected directory receives its own stub — `.claude/`, `.cursor/`, `.opencode/`, and `.agents/` are peer targets, and no directory is special-cased or routed onto another. A stub SHALL be an ordinary file (never a symlink). A skill stub SHALL contain valid YAML frontmatter with `name` and `description` copied from the canonical skill so the tool can discover and trigger it, plus a `metadata` block carrying `type: shim` (which marks the file as a reference stub) and the canonical `version` (carried for reference and kept in lockstep with the canonical content). Its body SHALL instruct the agent to read the canonical file (`.taskless/skills//SKILL.md`) and follow it, and SHALL NOT duplicate the canonical content inline. Every stub SHALL point directly at a canonical file, never at another stub. -The CLI SHALL NOT create symlinks for any tool, for skills or commands. +The CLI SHALL NOT create symlinks for any tool. The per-directory stub layout is: -- `.claude/skills//SKILL.md` and `.claude/commands/tskl/.md` — Claude Code. -- `.cursor/skills//SKILL.md` and `.cursor/commands/tskl/.md` — Cursor. -- `.opencode/skills//SKILL.md` — OpenCode (no command stub). -- `.agents/skills//SKILL.md` — generic Agent Skills location, including Codex (no command stub). +- `.claude/skills//SKILL.md` — Claude Code. +- `.cursor/skills//SKILL.md` — Cursor. +- `.opencode/skills//SKILL.md` — OpenCode. +- `.agents/skills//SKILL.md` — generic Agent Skills location, including Codex. + +No directory receives a command stub. #### Scenario: Skill stub has valid frontmatter and a delegating body @@ -594,7 +565,7 @@ The per-directory stub layout is: #### Scenario: No symlinks are created - **WHEN** any `taskless init` or `taskless update` run completes -- **THEN** no skill or command file or directory written by the CLI SHALL be a symlink +- **THEN** no skill file or directory written by the CLI SHALL be a symlink ### Requirement: Install manifest records a per-target install mode @@ -613,7 +584,7 @@ Each target entry in `.taskless/taskless.json` install state SHALL record a `mod ### Requirement: Update rewrites canonical content and preserves reference stubs -`taskless update` SHALL rewrite the canonical `.taskless/skills/` and `.taskless/commands/` content from the embedded bundle. A canonical file whose bytes already equal what the bundle would write SHALL be left untouched and SHALL NOT be reported as written: the install summary, the `--json` `targets` field, and the upgrade trailer all describe what changed on disk, and a byte-identical rewrite is not a change. For `reference`-mode targets, update SHALL create a stub only if it is missing, and SHALL NOT overwrite an existing stub with full canonical content. Update SHALL re-generate a stub in place only when its frontmatter `name`, `description`, or `metadata.version` has drifted from the canonical content; the stub's delegating body SHALL be preserved. +`taskless update` SHALL rewrite the canonical `.taskless/skills/` content from the embedded bundle. A canonical file whose bytes already equal what the bundle would write SHALL be left untouched and SHALL NOT be reported as written: the install summary, the `--json` `targets` field, and the upgrade trailer all describe what changed on disk, and a byte-identical rewrite is not a change. For `reference`-mode targets, update SHALL create a stub only if it is missing, and SHALL NOT overwrite an existing stub with full canonical content. Update SHALL re-generate a stub in place only when its frontmatter `name`, `description`, or `metadata.version` has drifted from the canonical content; the stub's delegating body SHALL be preserved. Update SHALL NOT delete or `rm -rf` the canonical `.taskless/` store, nor any directory that another target sources content from. Removal logic SHALL operate only on entries recorded in the prior manifest and SHALL respect each entry's `mode`. @@ -637,7 +608,7 @@ Update SHALL NOT delete or `rm -rf` the canonical `.taskless/` store, nor any di #### Scenario: Update never destroys the canonical store - **WHEN** `taskless update` processes its targets -- **THEN** it SHALL NOT delete `.taskless/skills/` or `.taskless/commands/` as part of cleaning up any target +- **THEN** it SHALL NOT delete `.taskless/skills/` as part of cleaning up any target - **AND** the canonical content SHALL remain readable throughout the update ### Requirement: Existing installs converge to the canonical-plus-stub layout @@ -660,8 +631,6 @@ When a prior install left a full skill or command copy in a tool directory, or l A reference stub's body SHALL state what to do when the canonical file it points at is not present, naming a command that restores it. A stub that only delegates leaves a reader who follows it at a dead end, and the two states that produce a missing canonical file (an install whose untracked files never reached this working directory, and a project that ignores the canonical store) are both reached without anyone doing anything wrong. -Skill stubs and command stubs SHALL carry the same instruction. - The command named SHALL be the one that runs the build which wrote the stub, resolved the same way canonical content resolves it. A stub emitted by a development or nightly build SHALL NOT direct the reader to the released package. The command named SHALL install without an interactive terminal, since the reader of a stub is typically an agent in a non-interactive context. @@ -685,7 +654,7 @@ An install SHALL also reclaim a stub whose recovery command names a build other #### Scenario: Stub names the command that restores a missing canonical file -- **WHEN** the CLI writes a skill stub or a command stub +- **WHEN** the CLI writes a skill stub - **THEN** its body SHALL say what to do if the canonical file does not exist - **AND** SHALL name a command that restores it @@ -895,3 +864,36 @@ Under `--json`, the envelope SHALL carry `pinnedCli`: an array of `{ manifest, l - **WHEN** a workspace pattern is negated, is absolute, climbs out with `..`, or is `**` over a tree that contains `node_modules` - **THEN** a directory the negation matches SHALL NOT be reported - **AND** nothing outside the working directory, and no `package.json` under `node_modules`, SHALL be read as a workspace package + +### Requirement: An upgrade removes the retired tskl command + +Taskless no longer ships a slash command. The install manifest SHALL keep recording a `commands` list per target, so that an install over an older one removes any `tskl.md` command the prior manifest recorded, from the canonical `.taskless/` store and from each tool directory. Each removal SHALL be confined to the entries the prior manifest recorded for that target. + +After removing a recorded command, the CLI SHALL remove that target's `commands/tskl/` directory when it is empty, and for the canonical `.taskless/` target SHALL also remove `.taskless/commands/` when it is empty. A tool directory's own `commands/` directory and any other file in it SHALL be left in place. + +When a target's `commands/tskl/` is a symlink, the CLI SHALL remove the symlink itself and SHALL NOT delete any file through it. + +The `taskless init --json` envelope SHALL report each removed command in that target's `removedCommands`. + +#### Scenario: A recorded command is removed from the canonical store and a tool directory + +- **WHEN** the prior manifest records `tskl.md` under both `.taskless` and `.claude`, and `taskless init` runs +- **THEN** `.taskless/commands/tskl/tskl.md` and `.claude/commands/tskl/tskl.md` SHALL be deleted +- **AND** the new manifest SHALL record no commands for either target + +#### Scenario: Empty directories go, a tool's other commands stay + +- **WHEN** the recorded command is removed and `.claude/commands/` also holds a file Taskless did not write +- **THEN** `.claude/commands/tskl/` and `.taskless/commands/` SHALL no longer exist +- **AND** the other file in `.claude/commands/` SHALL be left untouched + +#### Scenario: A symlinked command directory is unlinked, not deleted through + +- **WHEN** `.claude/commands/tskl` is a symlink to a directory elsewhere and the prior manifest records `tskl.md` under `.claude` +- **THEN** the symlink SHALL be removed +- **AND** the file it pointed at SHALL be left untouched + +#### Scenario: The JSON envelope reports what went + +- **WHEN** `taskless init --json` removes a recorded command +- **THEN** the affected target's `removedCommands` SHALL list `tskl.md` diff --git a/openspec/specs/infrastructure/spec.md b/openspec/specs/infrastructure/spec.md index a03915b2..620370ab 100644 --- a/openspec/specs/infrastructure/spec.md +++ b/openspec/specs/infrastructure/spec.md @@ -43,17 +43,6 @@ A `scripts/sync-skill-versions.ts` script SHALL read the version from `packages/ - **WHEN** 5 SKILL.md files exist under `skills/` - **THEN** the script SHALL update all 5 files' `metadata.version` fields -### Requirement: Slash command files are hand-authored - -The single `commands/tskl/tskl.md` slash command SHALL be hand-authored rather than generated from a `SKILL.md` body. Its body intentionally differs from the skill body (it is a `$ARGUMENTS`-aware router), so the prior "copy SKILL.md body to command" generation script SHALL NOT be reintroduced. - -#### Scenario: Single hand-authored command file exists - -- **WHEN** inspecting the repository -- **THEN** `commands/tskl/tskl.md` SHALL exist as a hand-authored file -- **AND** there SHALL be no `scripts/generate-commands.ts` script -- **AND** the root `package.json` SHALL NOT reference `build:generate-commands` - ### Requirement: Version sync runs as part of changeset version The root `package.json` SHALL define a `version` script that runs `changeset version` followed by `tsx scripts/sync-skill-versions.ts`. This ensures skill versions are updated in the same commit as the package version bump. diff --git a/openspec/specs/skill-taskless/spec.md b/openspec/specs/skill-taskless/spec.md index e9e3613e..40c91ce0 100644 --- a/openspec/specs/skill-taskless/spec.md +++ b/openspec/specs/skill-taskless/spec.md @@ -73,7 +73,7 @@ The consolidated skill body SHALL NOT contain step-by-step instructions for any - 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. The `tskl` command body SHALL carry the same statement. +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.) @@ -85,19 +85,19 @@ The body SHALL be no more than 150 lines of markdown. The cap guards against unb #### Scenario: Skill body spells the invocation as a placeholder -- **WHEN** the skill body or the `tskl` command body is read from source +- **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 or the `tskl` command body is read by an agent +- **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 `tskl agent ` invocations +- **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 @@ -123,31 +123,6 @@ The body SHALL be no more than 150 lines of markdown. The cap guards against unb - **WHEN** the skill body is measured - **THEN** it SHALL be no more than 150 lines of markdown -### Requirement: Skill maps to a single tskl command - -The consolidated skill's frontmatter SHALL include `metadata.commandName: tskl` so that command-installation plumbing maps the skill to the new single command file at `commands/tskl/tskl.md`. The command file SHALL be a thin doorway that accepts a free-form `$ARGUMENTS` ask, infers a topic if possible, and otherwise asks the user what they want to do. - -#### Scenario: Frontmatter declares the command mapping - -- **WHEN** the skill frontmatter is parsed -- **THEN** `metadata.commandName` SHALL equal `tskl` - -#### Scenario: Old command files are removed - -- **WHEN** the v0.7.0 release is built -- **THEN** none of `commands/tskl/check.md`, `commands/tskl/improve.md`, `commands/tskl/info.md`, `commands/tskl/login.md`, `commands/tskl/logout.md`, or `commands/tskl/rule.md` SHALL exist in the repository -- **AND** exactly one file `commands/tskl/tskl.md` SHALL exist - -#### Scenario: Slash command accepts free-form arguments - -- **WHEN** a user invokes `/tskl` with arguments (e.g. `/tskl create a rule for no console.log`) -- **THEN** the command body SHALL instruct the agent to infer the topic from `$ARGUMENTS`, fetch the recipe via `npx @taskless/cli help `, and proceed - -#### Scenario: Slash command without arguments asks the user - -- **WHEN** a user invokes `/tskl` with no arguments -- **THEN** the command body SHALL instruct the agent to ask the user what they want to do with Taskless before proceeding - ### Requirement: Skill routes authoring requests through the routing front door The skill body SHALL route rule-authoring requests through `npx @taskless/cli help route` @@ -185,24 +160,37 @@ The skill body's topic table SHALL map a request to make, publish, regenerate, o ### Requirement: Skill preflights the CLI before the first Taskless command -The skill body and the `tskl` command 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. +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. -When the preflight is blocked by the harness or the user, the bodies SHALL instruct the agent to: +When the preflight is blocked by the harness or the user, the body SHALL instruct the agent to: 1. Stop before doing any Taskless work, and not attempt to work around the block. -2. Tell the user the exact command, and that it downloads and runs a package from npm. -3. Offer the user three ways forward, and wait for their choice: the agent adds an allow rule for exactly the invocation the skill names to the agent's permission settings; the user approves it through their agent's permissions command, such as `/permissions` in Claude Code; or the user approves every Taskless command manually, in chat, as the agent runs it. +2. Send one message that names the exact command, and says it downloads and runs a package from npm. +3. 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. 4. Never propose an allow rule that widens the invocation, such as a wildcard over the package name or its version. +5. 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. #### Scenario: Skill body runs the preflight first -- **WHEN** the skill body or the `tskl` command body is read from source +- **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 or the `tskl` command 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 -- **AND** SHALL offer an allow rule for the exact invocation, the agent's permissions command, and per-command approval in chat +- **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 forbid asking what the user wants Taskless to do in that message + +### Requirement: A request that names no task routes to onboarding + +The skill body SHALL route a request to use, try, or set up Taskless that names no task to `%(TASKLESS_CLI)s agent onboard`, without first offering a menu of topics. The onboard recipe's question SHALL be the only question the agent asks before onboarding begins. + +#### Scenario: A generic request goes to onboard + +- **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 diff --git a/openspec/specs/skills/spec.md b/openspec/specs/skills/spec.md index 09a639c5..526b5e0b 100644 --- a/openspec/specs/skills/spec.md +++ b/openspec/specs/skills/spec.md @@ -8,7 +8,7 @@ Defines the structure, conventions, and distribution model for Taskless skills, ### Requirement: Skills use SKILL.md format with YAML frontmatter -The single skill SHALL be defined at `skills/taskless/SKILL.md` with YAML frontmatter (`name`, `description`, `metadata`) followed by markdown instructions. The `name` field SHALL be exactly `taskless` (no per-task prefix). The `metadata` field SHALL include `author`, `version`, and `commandName: tskl` keys. The `version` SHALL be used for staleness detection when the skill is installed into target repositories. +The single skill SHALL be defined at `skills/taskless/SKILL.md` with YAML frontmatter (`name`, `description`, `metadata`) followed by markdown instructions. The `name` field SHALL be exactly `taskless` (no per-task prefix). The `metadata` field SHALL include `author` and `version` keys. The `version` SHALL be used for staleness detection when the skill is installed into target repositories. The skill body SHALL begin by instructing the agent that it does NOT have step-by-step instructions for any Taskless action and that recipes must be fetched via `npx @taskless/cli agent ` before proceeding. The body SHALL NOT contain inline step-by-step recipes for any individual task — those live in `packages/cli/src/agent/.md` files served by the `agent` subcommand. @@ -17,7 +17,7 @@ The skill body SHALL begin by instructing the agent that it does NOT have step-b - **WHEN** the skill is built - **THEN** `skills/taskless/SKILL.md` SHALL exist - **AND** SHALL contain YAML frontmatter with `name: taskless` and `description` (up to 1024 chars) -- **AND** the `metadata` field SHALL include `author: taskless`, `version` (string matching CLI package version), and `commandName: tskl` +- **AND** the `metadata` field SHALL include `author: taskless` and `version` (string matching CLI package version) - **AND** the markdown body SHALL contain the router instructions described above (no per-task recipes inline) #### Scenario: Skill body delegates to the CLI agent command @@ -57,23 +57,6 @@ The single skill name SHALL be `taskless` (without a per-task suffix). When inst - **THEN** the directory name SHALL be `taskless` - **AND** SHALL NOT use a `taskless-` per-task name -### Requirement: Commands directory contains Claude Code command files - -The `commands/tskl/` directory SHALL contain exactly one command file (`tskl.md`) that maps to the consolidated skill. The command body SHALL accept a free-form `$ARGUMENTS` ask and route via the same flow as the skill (fetch `npx @taskless/cli agent `, follow the recipe). When `$ARGUMENTS` is empty or ambiguous, the command body SHALL instruct the agent to ask the user what they want to do. - -#### Scenario: Single command file exists - -- **WHEN** the v0.7.0 release is built - -- **THEN** `commands/tskl/tskl.md` SHALL exist -- **AND** no other command files SHALL exist in `commands/tskl/` - -#### Scenario: Command file is a router - -- **WHEN** the command body is read -- **THEN** it SHALL instruct the agent to handle `$ARGUMENTS` by inferring a topic, fetching its recipe, and proceeding -- **AND** SHALL specify behavior when `$ARGUMENTS` is empty (ask the user) - ### Requirement: Claude Code Plugin Marketplace manifest exists A `.claude-plugin/marketplace.json` file SHALL exist at the repository root defining a marketplace with a single `taskless` plugin. The marketplace SHALL conform to the Claude Code marketplace schema. @@ -91,14 +74,13 @@ A `.claude-plugin/marketplace.json` file SHALL exist at the repository root defi ### Requirement: Plugin manifest declares skills and commands -The `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` SHALL declare the single consolidated skill and the single command. The plugin version SHALL be `0.7.0` for this release. The plugin description MAY be updated to reflect the consolidation. +The `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` SHALL declare the single consolidated skill and no commands. The plugin version SHALL be `0.7.0` for this release. The plugin description MAY be updated to reflect the consolidation. #### Scenario: Plugin manifest reflects consolidated bundle - **WHEN** `plugin.json` is read - **THEN** `version` SHALL be `0.7.0` -- **AND** `commands` SHALL point to `./commands/tskl/` -- **AND** the bundled commands directory SHALL contain only `tskl.md` +- **AND** it SHALL NOT declare `commands` ### Requirement: Three distribution channels are supported diff --git a/packages/cli/README.md b/packages/cli/README.md index 12d8d5ea..df3605e5 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -28,17 +28,10 @@ agent through your codebase and suggests a starter set of rules. ## How to Use via Agents -Installing adds one skill (`taskless`) and one slash command (`/tskl`). The skill -body is a small router: your agent fetches the canonical recipe for whatever you -asked for, then follows it. - -``` -/tskl create a rule that bans console.log -/tskl add taskless to CI -``` - -Plain language works too: "write a taskless rule for X", "run taskless check", -"taskless login" all engage the skill. You rarely need to run the CLI yourself. +Installing adds one skill (`taskless`). The skill body is a small router: your +agent fetches the canonical recipe for whatever you asked for, then follows it. +Ask in plain language: "write a taskless rule that bans console.log", "add +taskless to CI", "run taskless check". You rarely need to run the CLI yourself. To see what the agent sees, run `npx @taskless/cli agent` for the topic index, or `npx @taskless/cli agent ` for a full recipe. @@ -61,7 +54,7 @@ calls, so CI needs no secrets. Runtime rules (which execute code) only run once the server has verified their signature; otherwise they are reported as skipped and never change the exit code. -Ask your agent to `/tskl add taskless to CI` and it will wire this into the CI +Ask your agent to "add taskless to CI" and it will wire this into the CI system you already use rather than replacing it. ## Why Teams Choose Taskless @@ -71,8 +64,8 @@ system you already use rather than replacing it. agent and every human. - **The same rules in the editor and in CI.** One command, one exit code. - **Works with the agent you already have.** One skill installs into Claude Code, - Cursor, and OpenCode, plus the `/tskl` command wherever the tool supports slash - commands, with a plain `.agents/` fallback when none is detected. + Cursor, Codex, and OpenCode, with a plain `.agents/` fallback when none is + detected. - **Nothing to run locally.** No daemon, no install step in CI, no auth for the checks that matter most. diff --git a/packages/cli/src/agent/init.md b/packages/cli/src/agent/init.md index 4f94986a..f76c1a6f 100644 --- a/packages/cli/src/agent/init.md +++ b/packages/cli/src/agent/init.md @@ -1,4 +1,4 @@ -# Topic: init (CLI v%(CLI_VERSION)s / topic v5) +# Topic: init (CLI v%(CLI_VERSION)s / topic v6) ## Goal Install or update the Taskless skill in this project, and migrate the @@ -29,15 +29,14 @@ first step, not the whole job. ```json { "success": true, - "commandsInstalled": true, "cliVersion": { "previous": "0.10.2", "installed": "0.11.1" }, "targets": [ { "dir": ".taskless", "mode": "canonical", - "writtenSkills": ["taskless"], "writtenCommands": [], + "writtenSkills": ["taskless"], "removedSkills": [], "removedCommands": [] }, { "dir": ".claude", "mode": "reference", - "writtenSkills": ["taskless"], "writtenCommands": ["tskl.md"], - "removedSkills": [], "removedCommands": [] } + "writtenSkills": ["taskless"], + "removedSkills": [], "removedCommands": ["tskl.md"] } ], "changed": true, "migrated": { "from": 3, "to": 4, "applied": [4], @@ -117,18 +116,18 @@ first step, not the whole job. `%(TASKLESS_CLI)s` with no subcommand launches an interactive wizard. It detects the installed tools (Claude Code, OpenCode, Cursor, Codex), asks which to enable, offers a login (skippable), shows a diff against the -previous install, then writes the canonical `taskless` skill (and `tskl` -command) once to `.taskless/` and a thin stub into each selected tool -directory. Stale layouts from older installs converge to stubs -automatically; v0.6-era per-task skills and commands are removed and the -summary shows what went. +previous install, then writes the canonical `taskless` skill once to +`.taskless/` and a thin stub into each selected tool directory. Stale +layouts from older installs converge to stubs automatically; the retired +`tskl` slash command and v0.6-era per-task skills and commands are removed, +and the summary shows what went. ## Errors - `SCAFFOLD_VERSION_MISMATCH`: the project's `.taskless/` is NEWER than this CLI. Upgrade the CLI; do not migrate downward. -- No tools detected → the skill is written to `.agents/skills/` and no - slash command is installed. Not an error. +- No tools detected → the skill is written to `.agents/skills/`. Not an + error. - Wizard cancelled (Ctrl-C) → no filesystem writes. Re-run when ready. ## See Also diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 8e96a92f..9ab3f0bd 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -9,7 +9,6 @@ import { DEFAULT_SHIM_DIR, detectSelectedDirectories, detectTools, - getEmbeddedCommands, getEmbeddedSkills, } from "../install/install"; import { getMandatorySkillNames } from "../install/catalog"; @@ -111,7 +110,6 @@ export const initCommand = defineCommand({ console.log( JSON.stringify({ success: true, - commandsInstalled: result.commandsInstalled, // `null` rather than absent when nothing was recorded, so a consumer // reads "fresh project" as a value and never has to probe for a key. cliVersion: { @@ -306,7 +304,6 @@ interface TargetOutcome { dir: string; mode: InstallMode; writtenSkills: string[]; - writtenCommands: string[]; removedSkills: string[]; removedCommands: string[]; } @@ -314,7 +311,6 @@ interface TargetOutcome { function targetChanged(target: TargetOutcome): boolean { return ( target.writtenSkills.length > 0 || - target.writtenCommands.length > 0 || target.removedSkills.length > 0 || target.removedCommands.length > 0 ); @@ -324,7 +320,6 @@ async function runNonInteractive( cwd: string, options: { json?: boolean } = {} ): Promise<{ - commandsInstalled: boolean; reloadNotice: string | undefined; migrated: MigrationReport | undefined; previousCliVersion: string | undefined; @@ -337,7 +332,7 @@ async function runNonInteractive( }> { // Under `--json`, stdout carries only the envelope printed by the caller. // This per-target summary is not on that envelope (it is finer-grained than - // `migrated`/`commandsInstalled`), so rather than drop it, it goes to + // `migrated`), so rather than drop it, it goes to // stderr — visible to a person watching the terminal, invisible to a // machine consumer parsing stdout. const log = options.json ? console.error : console.log; @@ -381,14 +376,10 @@ async function runNonInteractive( const allSkills = getEmbeddedSkills(); const mandatoryNames = new Set(getMandatorySkillNames()); const skills = allSkills.filter((s) => mandatoryNames.has(s.name)); - const commands = getEmbeddedCommands(); const detected = await detectTools(cwd); const selectedDirectories = await detectSelectedDirectories(cwd); - const plan = buildInstallPlan(selectedDirectories, skills, commands); - const commandsInstalled = plan.targets.some( - (t) => t.mode === "reference" && t.commands.length > 0 - ); + const plan = buildInstallPlan(selectedDirectories, skills); // Read BEFORE applying: `applyInstallPlan` records the new version, so // afterwards there is nothing left to compare against. @@ -408,12 +399,6 @@ async function runNonInteractive( value: entry.skill, })) ); - const commandsByTarget = groupValuesByTarget( - result.writtenCommands.map((entry) => ({ - target: entry.target, - value: entry.command, - })) - ); const removedSkillsByTarget = groupValuesByTarget( result.removedSkills.map((entry) => ({ target: entry.target, @@ -430,7 +415,6 @@ async function runNonInteractive( const targets: TargetOutcome[] = []; for (const target of plan.targets) { const writtenSkills = skillsByTarget.get(target.dir) ?? []; - const writtenCommands = commandsByTarget.get(target.dir) ?? []; const removedSkills = removedSkillsByTarget.get(target.dir) ?? []; const removedCommands = removedCommandsByTarget.get(target.dir) ?? []; const noun = target.mode === "canonical" ? "canonical file" : "stub"; @@ -438,7 +422,6 @@ async function runNonInteractive( dir: target.dir, mode: target.mode, writtenSkills, - writtenCommands, removedSkills, removedCommands, }; @@ -455,9 +438,6 @@ async function runNonInteractive( for (const name of writtenSkills) { log(` - ${name}`); } - if (writtenCommands.length > 0) { - log(` + ${String(writtenCommands.length)} command ${noun}(s)`); - } if (removedSkills.length > 0) { log(` removed ${String(removedSkills.length)} obsolete skill(s):`); for (const name of removedSkills) { @@ -473,7 +453,6 @@ async function runNonInteractive( } return { - commandsInstalled, reloadNotice, migrated, previousCliVersion, diff --git a/packages/cli/src/filesystem/migrations/0001-init.ts b/packages/cli/src/filesystem/migrations/0001-init.ts index a5fb0ab6..0ee5558b 100644 --- a/packages/cli/src/filesystem/migrations/0001-init.ts +++ b/packages/cli/src/filesystem/migrations/0001-init.ts @@ -78,7 +78,6 @@ ${usageBlock(specifier)} - \`taskless.json\` - Version manifest / migration state - \`.env.local.json\` - Local authentication credentials (git-ignored) - \`skills/\` - Canonical Taskless skill content; tool directories hold thin stubs that delegate here (managed by Taskless) -- \`commands/\` - Canonical Taskless command content (managed by Taskless) ${layoutBlock()} `; diff --git a/packages/cli/src/install/canonical.ts b/packages/cli/src/install/canonical.ts index 6e97d877..d92bcccc 100644 --- a/packages/cli/src/install/canonical.ts +++ b/packages/cli/src/install/canonical.ts @@ -12,9 +12,9 @@ import { import { parseFrontmatter } from "./frontmatter"; /** - * The Taskless-owned namespace that holds canonical skill and command - * content. No tool target ever installs into or cleans up this directory, - * which is what makes the canonical content safe from destructive cleanup. + * The Taskless-owned namespace that holds canonical skill content. No tool + * target ever installs into or cleans up this directory, which is what makes + * the canonical content safe from destructive cleanup. */ const CANONICAL_DIR = ".taskless"; @@ -29,25 +29,11 @@ export interface StubFrontmatter { description: string; } -/** - * Command-stub frontmatter. Beyond `name`/`description`, the canonical - * command's `argument-hint` is preserved so the slash command keeps its - * editor argument hint. - */ -export interface CommandStubFrontmatter extends StubFrontmatter { - argumentHint?: string; -} - /** Workspace-relative path of the canonical skill file for `name`. */ export function canonicalSkillPath(name: string): string { return `${CANONICAL_DIR}/skills/${name}/SKILL.md`; } -/** Workspace-relative path of the canonical command file for `filename`. */ -export function canonicalCommandPath(filename: string): string { - return `${CANONICAL_DIR}/commands/tskl/${filename}`; -} - /** * Write a skill's full content to the canonical store at * `.taskless/skills//SKILL.md`. The canonical store is the single source @@ -94,28 +80,6 @@ async function writeIfChanged(path: string, content: string): Promise { return true; } -/** - * Write a command's full content to the canonical store at - * `.taskless/commands/tskl/`. Emitted as-is for prod builds; for - * `dev`/`self` builds the CLI invocation is rewritten and a build notice - * prepended (see {@link renderInvocationPlaceholder} / - * {@link withCliBuildNotice}). - */ -export async function writeCanonicalCommand( - cwd: string, - filename: string, - content: string -): Promise { - const directory = join(cwd, CANONICAL_DIR, "commands", "tskl"); - await mkdir(directory, { recursive: true }); - const path = join(directory, filename); - const changed = await writeIfChanged( - path, - withCliBuildNotice(renderInvocationPlaceholder(content)) - ); - return { path, changed }; -} - /** * Frontmatter `metadata` block stamped onto a stub. `type: shim` marks the * file as a reference stub so it is distinguishable from a full copy without @@ -289,34 +253,6 @@ export function buildSkillStub(meta: StubFrontmatter): string { ); } -/** - * Build a reference command stub: a real command file whose frontmatter - * carries `name`/`description` and whose body passes `$ARGUMENTS` through and - * delegates to the canonical command file. - */ -export function buildCommandStub( - meta: CommandStubFrontmatter, - filename: string -): string { - const canonical = canonicalCommandPath(filename); - const fields: Record = { - name: meta.name, - description: meta.description, - }; - if (meta.argumentHint) fields["argument-hint"] = meta.argumentHint; - fields.metadata = shimMetadata(); - return ( - frontmatterBlock(fields) + - "\n" + - `This command was invoked with: $ARGUMENTS\n\n` + - `This is a Taskless reference stub. The canonical command is defined at ` + - `\`${canonical}\`.\n\n` + - `Read \`${canonical}\` and follow its instructions, treating the text ` + - `above as the command arguments.\n\n` + - recoveryInstruction(canonical) - ); -} - /** * Report whether an existing stub's frontmatter has drifted from what would * be generated now. Used by `update` to decide whether a stub needs diff --git a/packages/cli/src/install/install.ts b/packages/cli/src/install/install.ts index ce65addd..01142518 100644 --- a/packages/cli/src/install/install.ts +++ b/packages/cli/src/install/install.ts @@ -1,16 +1,21 @@ -import { lstat, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { + lstat, + mkdir, + readFile, + rm, + rmdir, + stat, + writeFile, +} from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { - buildCommandStub, buildSkillStub, isShimStub, stubFrontmatterDrifted, stubPredatesRecovery, stubRecoveryInvocationStale, - writeCanonicalCommand, writeCanonicalSkill, - type CommandStubFrontmatter, type StubFrontmatter, } from "./canonical"; import { CLI_VERSION } from "../version"; @@ -29,14 +34,8 @@ const skillFiles: Record = import.meta.glob( { query: "?raw", import: "default", eager: true } ); -// Command files embedded at build time via Vite import.meta.glob -const commandFiles: Record = import.meta.glob( - "../../../../commands/tskl/**/*.md", - { query: "?raw", import: "default", eager: true } -); - /** - * Taskless-owned namespace holding the canonical skill/command content. It is + * Taskless-owned namespace holding the canonical skill content. It is * never a tool target — no detection, install, or cleanup logic points here. */ export const CANONICAL_DIR = ".taskless"; @@ -67,14 +66,6 @@ export interface EmbeddedSkill { metadata: Record; } -export interface EmbeddedCommand { - filename: string; - content: string; - name: string; - description: string; - argumentHint?: string; -} - export interface SkillStatus { name: string; installedVersion: string | undefined; @@ -140,8 +131,6 @@ export interface ShimTarget { dir: string; /** Human-readable label for prompts and summaries. */ label: string; - /** Whether this directory receives the `tskl` command stub. */ - commands: boolean; /** * Marks the catch-all row: the one offered to anyone whose harness this * catalog does not name. At most one row carries it. @@ -165,11 +154,11 @@ export interface ShimTarget { * label: the duplicate directory is the point, not an oversight. */ export const SHIM_TARGETS: readonly ShimTarget[] = [ - { dir: ".claude", label: "Claude Code", commands: true }, - { dir: ".agents", label: "Codex", commands: false }, - { dir: ".cursor", label: "Cursor", commands: true }, - { dir: ".opencode", label: "OpenCode", commands: false }, - { dir: ".agents", label: "Agent Skills", commands: false, generic: true }, + { dir: ".claude", label: "Claude Code" }, + { dir: ".agents", label: "Codex" }, + { dir: ".cursor", label: "Cursor" }, + { dir: ".opencode", label: "OpenCode" }, + { dir: ".agents", label: "Agent Skills", generic: true }, ]; /** @@ -184,10 +173,10 @@ export const SHIM_TARGETS: readonly ShimTarget[] = [ * * What it does NOT do is reconcile the rows: the surviving entry is one whole * row, so EVERY field comes from the last row for that directory, not just - * `label`. Today both `.agents/` rows agree on `commands`, so nothing is - * silently chosen. A future pair that disagreed on it would be resolved by - * catalog order rather than by anyone deciding, which is a reason to keep - * rows sharing a directory identical apart from `label` and `generic`. + * `label`. Today the rows carry nothing else, so nothing is silently chosen. + * A future field the pair disagreed on would be resolved by catalog order + * rather than by anyone deciding, which is a reason to keep rows sharing a + * directory identical apart from `label` and `generic`. * * The LAST row for a directory wins both its position and its value. The * catalog is ordered named-harness first, generic-fallback last, so `.agents/` @@ -314,27 +303,6 @@ export function getEmbeddedSkills(): EmbeddedSkill[] { }); } -// --- Embedded Commands --- - -export function getEmbeddedCommands(): EmbeddedCommand[] { - return Object.entries(commandFiles).map(([path, content]) => { - const parsed = parseFrontmatter(content); - const data = parsed.data as { - name?: string; - description?: string; - "argument-hint"?: string; - }; - const filename = basename(path); - return { - filename, - content, - name: data.name ?? filename.replace(/\.md$/, ""), - description: data.description ?? "", - argumentHint: data["argument-hint"], - }; - }); -} - // --- Install Plan --- /** @@ -346,7 +314,6 @@ export interface PlanTarget { label: string; mode: InstallMode; skills: EmbeddedSkill[]; - commands: EmbeddedCommand[]; } export interface InstallPlan { @@ -356,22 +323,20 @@ export interface InstallPlan { /** * Build an install plan: the always-present canonical `.taskless/` target * plus one `reference` stub target per selected directory. The canonical - * target is included whenever the plan carries any skill or command. + * target is included whenever the plan carries any skill. */ export function buildInstallPlan( selectedDirectories: readonly string[], - skills: EmbeddedSkill[], - commands: EmbeddedCommand[] + skills: EmbeddedSkill[] ): InstallPlan { const targets: PlanTarget[] = []; - if (skills.length > 0 || commands.length > 0) { + if (skills.length > 0) { targets.push({ dir: CANONICAL_DIR, label: "Taskless canonical store", mode: "canonical", skills, - commands, }); } @@ -385,7 +350,6 @@ export function buildInstallPlan( label: shim.label, mode: "reference", skills, - commands: shim.commands ? commands : [], }); } @@ -402,7 +366,9 @@ export function planToStateTargets(plan: InstallPlan): InstallState["targets"] { for (const target of plan.targets) { targets[target.dir] = { skills: target.skills.map((s) => s.name), - commands: target.commands.map((c) => c.filename), + // Taskless no longer ships a slash command. The field stays so the diff + // against an older manifest removes the `tskl` command it recorded. + commands: [], mode: target.mode, }; } @@ -418,7 +384,6 @@ export interface ApplyInstallOptions { export interface ApplyInstallResult { state: InstallState; writtenSkills: Array<{ target: string; skill: string }>; - writtenCommands: Array<{ target: string; command: string }>; removedSkills: Array<{ target: string; skill: string }>; removedCommands: Array<{ target: string; command: string }>; } @@ -526,41 +491,6 @@ async function writeSkill( return true; } -/** - * Write a command into a target. Mirrors {@link writeSkill}: full content for - * a `canonical` target, a self-healing shim stub for a `reference` target. - */ -async function writeCommand( - cwd: string, - target: PlanTarget, - command: EmbeddedCommand -): Promise { - if (target.mode === "canonical") { - const { changed } = await writeCanonicalCommand( - cwd, - command.filename, - command.content - ); - return changed; - } - - const path = commandFile(cwd, target.dir, command.filename); - // As in writeSkill: a symlinked command namespace directory would otherwise - // route the stub write back into source. Normalize it to a real directory. - await unlinkIfSymlink(dirname(path)); - const meta: CommandStubFrontmatter = { - name: command.name, - description: command.description, - argumentHint: command.argumentHint, - }; - if (!(await referenceNeedsRewrite(path, meta))) return false; - - await unlinkIfSymlink(path); - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, buildCommandStub(meta, command.filename), "utf8"); - return true; -} - /** * Apply an install plan. The previous manifest state drives surgical cleanup: * only skills/commands recorded for a target and no longer present are @@ -596,13 +526,23 @@ export async function applyInstallPlan( removedSkills.push({ target: entry.target, skill: skillName }); } for (const filename of entry.removals.commands) { - await rm(commandFile(cwd, entry.target, filename), { force: true }); + const path = commandFile(cwd, entry.target, filename); + // A relic `tskl/` symlink points back at source; removing through it + // would delete the file it points at. Drop the link itself instead. + await unlinkIfSymlink(dirname(path)); + await rm(path, { force: true }); + // The `tskl/` namespace is ours; drop it once empty. A directory that + // still holds something is not, so the failure is the answer. + await rmdir(dirname(path)).catch(() => {}); + // The canonical store's `commands/` holds nothing else of anyone's. + if (entry.target === CANONICAL_DIR) { + await rmdir(dirname(dirname(path))).catch(() => {}); + } removedCommands.push({ target: entry.target, command: filename }); } } const writtenSkills: Array<{ target: string; skill: string }> = []; - const writtenCommands: Array<{ target: string; command: string }> = []; for (const target of plan.targets) { for (const skill of target.skills) { @@ -610,11 +550,6 @@ export async function applyInstallPlan( writtenSkills.push({ target: target.dir, skill: skill.name }); } } - for (const command of target.commands) { - if (await writeCommand(cwd, target, command)) { - writtenCommands.push({ target: target.dir, command: command.filename }); - } - } } await writeInstallState(cwd, nextState); @@ -622,7 +557,6 @@ export async function applyInstallPlan( return { state: nextState, writtenSkills, - writtenCommands, removedSkills, removedCommands, }; diff --git a/packages/cli/src/wizard/index.ts b/packages/cli/src/wizard/index.ts index 48e5872e..a746c89c 100644 --- a/packages/cli/src/wizard/index.ts +++ b/packages/cli/src/wizard/index.ts @@ -5,7 +5,6 @@ import { ensureTasklessDirectory } from "../filesystem/directory"; import { applyInstallPlan, buildInstallPlan, - getEmbeddedCommands, getEmbeddedSkills, planToStateTargets, } from "../install/install"; @@ -51,11 +50,7 @@ export async function runWizard( locations = await promptLocations(options.cwd); // Catalog has one entry now (`taskless`); install all embedded skills. - const plan = buildInstallPlan( - locations, - getEmbeddedSkills(), - getEmbeddedCommands() - ); + const plan = buildInstallPlan(locations, getEmbeddedSkills()); const previousState = await readInstallState(options.cwd); const diff = computeInstallDiff(previousState, { @@ -119,7 +114,7 @@ export async function runWizard( function finish(args: { status: "completed" | "cancelled" }): WizardResult { const durationMs = Date.now() - start; if (args.status === "completed") { - // Concrete state event: skills/commands were installed (interactive). + // Concrete state event: skills were installed (interactive). telemetry.capture("cli_installed"); } return { diff --git a/packages/cli/test/apply-install-plan.test.ts b/packages/cli/test/apply-install-plan.test.ts index ea5f8b8a..d325b694 100644 --- a/packages/cli/test/apply-install-plan.test.ts +++ b/packages/cli/test/apply-install-plan.test.ts @@ -15,7 +15,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { applyInstallPlan, buildInstallPlan, - getEmbeddedCommands, getEmbeddedSkills, } from "../src/install/install"; import { renderInvocationPlaceholder } from "../src/util/invocation"; @@ -61,9 +60,7 @@ function tasklessSkill() { describe("applyInstallPlan", () => { it("writes canonical full content and a reference stub, recording modes", async () => { - const skills = [tasklessSkill()]; - const commands = getEmbeddedCommands(); - const plan = buildInstallPlan([".claude"], skills, commands); + const plan = buildInstallPlan([".claude"], [tasklessSkill()]); const result = await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); @@ -94,30 +91,64 @@ describe("applyInstallPlan", () => { expect(result.writtenSkills.length).toBeGreaterThan(0); }); - it("writes a command stub only for command-capable targets", async () => { - const plan = buildInstallPlan( - [".claude", ".opencode"], - [tasklessSkill()], - getEmbeddedCommands() - ); + it("writes no slash command into any target", async () => { + const plan = buildInstallPlan([".claude", ".cursor"], [tasklessSkill()]); await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); - expect( - await exists(join(cwd, ".claude", "commands", "tskl", "tskl.md")) - ).toBe(true); - expect(await exists(join(cwd, ".opencode", "commands"))).toBe(false); - // .opencode still gets its skill stub. - expect( - await exists(join(cwd, ".opencode", "skills", "taskless", "SKILL.md")) - ).toBe(true); + for (const directory of [".taskless", ".claude", ".cursor"]) { + expect(await exists(join(cwd, directory, "commands"))).toBe(false); + } }); - it("reports neither an unchanged canonical file nor an unchanged reference stub", async () => { - const plan = buildInstallPlan( - [".claude"], - [tasklessSkill()], - getEmbeddedCommands() + it("removes the tskl command an older install recorded", async () => { + // Taskless used to ship `/tskl`: full content in the canonical store and + // a stub in each command-capable tool. An upgrade has to take both away. + const canonicalCommands = join(cwd, ".taskless", "commands", "tskl"); + const claudeCommands = join(cwd, ".claude", "commands"); + await mkdir(canonicalCommands, { recursive: true }); + await writeFile(join(canonicalCommands, "tskl.md"), "old", "utf8"); + await mkdir(join(claudeCommands, "tskl"), { recursive: true }); + await writeFile(join(claudeCommands, "tskl", "tskl.md"), "old", "utf8"); + // Someone else's command shares the tool's `commands/` directory. + await writeFile(join(claudeCommands, "theirs.md"), "keep", "utf8"); + await writeInstallState(cwd, { + cliVersion: "0.12.0", + targets: { + ".taskless": { + skills: ["taskless"], + commands: ["tskl.md"], + mode: "canonical", + }, + ".claude": { + skills: ["taskless"], + commands: ["tskl.md"], + mode: "reference", + }, + }, + }); + + const result = await applyInstallPlan( + cwd, + buildInstallPlan([".claude"], [tasklessSkill()]), + { cliVersion: "0.13.0" } + ); + + expect(result.removedCommands).toEqual([ + { target: ".claude", command: "tskl.md" }, + { target: ".taskless", command: "tskl.md" }, + ]); + expect(await exists(join(cwd, ".taskless", "commands"))).toBe(false); + expect(await exists(join(claudeCommands, "tskl"))).toBe(false); + expect(await readFile(join(claudeCommands, "theirs.md"), "utf8")).toBe( + "keep" ); + const state = await readInstallState(cwd); + expect(state.targets[".claude"]?.commands).toEqual([]); + expect(state.targets[".taskless"]?.commands).toEqual([]); + }); + + it("reports neither an unchanged canonical file nor an unchanged reference stub", async () => { + const plan = buildInstallPlan([".claude"], [tasklessSkill()]); await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); const second = await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); @@ -125,12 +156,11 @@ describe("applyInstallPlan", () => { // such, so a no-op re-install read as an upgrade. Identical bytes are // not a write, in either mode. expect(second.writtenSkills).toHaveLength(0); - expect(second.writtenCommands).toHaveLength(0); expect(second.removedSkills).toHaveLength(0); }); it("reports the canonical file when its bytes changed", async () => { - const plan = buildInstallPlan([".claude"], [tasklessSkill()], []); + const plan = buildInstallPlan([".claude"], [tasklessSkill()]); await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); await writeFile( join(cwd, ".taskless", "skills", "taskless", "SKILL.md"), @@ -146,7 +176,7 @@ describe("applyInstallPlan", () => { }); it("does not clobber an existing reference stub on re-run", async () => { - const plan = buildInstallPlan([".claude"], [tasklessSkill()], []); + const plan = buildInstallPlan([".claude"], [tasklessSkill()]); await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); const stubPath = join(cwd, ".claude", "skills", "taskless", "SKILL.md"); @@ -174,7 +204,7 @@ describe("applyInstallPlan", () => { }, }); - const plan = buildInstallPlan([".claude"], [tasklessSkill()], []); + const plan = buildInstallPlan([".claude"], [tasklessSkill()]); await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); // The obsolete .claude skill is gone; the canonical store is intact. @@ -221,11 +251,7 @@ describe("applyInstallPlan", () => { }, }); - const plan = buildInstallPlan( - [".claude"], - [tasklessSkill()], - getEmbeddedCommands() - ); + const plan = buildInstallPlan([".claude"], [tasklessSkill()]); const result = await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); expect(result.removedSkills).toHaveLength(v6Skills.length); @@ -241,7 +267,7 @@ describe("applyInstallPlan", () => { it("writes a stub whose bytes do not move with the CLI version", async () => { // The stub footprint outside `.taskless` is deliberately version-free, so // two installs of different CLI versions must produce identical bytes. - const plan = buildInstallPlan([".claude"], [tasklessSkill()], []); + const plan = buildInstallPlan([".claude"], [tasklessSkill()]); const stubPath = join(cwd, ".claude", "skills", "taskless", "SKILL.md"); await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); @@ -279,7 +305,7 @@ describe("applyInstallPlan", () => { await mkdir(dirname(claudeSkill), { recursive: true }); await writeFile(claudeSkill, legacy, "utf8"); - await applyInstallPlan(cwd, buildInstallPlan([".claude"], [skill], []), { + await applyInstallPlan(cwd, buildInstallPlan([".claude"], [skill]), { cliVersion: "0.7.0", }); @@ -296,7 +322,7 @@ describe("applyInstallPlan", () => { // reaches for when the canonical file is already missing. const skill = tasklessSkill(); const claudeSkill = join(cwd, ".claude", "skills", "taskless", "SKILL.md"); - const plan = buildInstallPlan([".claude"], [skill], []); + const plan = buildInstallPlan([".claude"], [skill]); await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); const written = await readFile(claudeSkill, "utf8"); @@ -326,7 +352,7 @@ describe("applyInstallPlan", () => { await writeFile(claudeSkill, skill.content, "utf8"); expect(isShimStub(await readFile(claudeSkill, "utf8"))).toBe(false); - await applyInstallPlan(cwd, buildInstallPlan([".claude"], [skill], []), { + await applyInstallPlan(cwd, buildInstallPlan([".claude"], [skill]), { cliVersion: "0.7.0", }); @@ -345,7 +371,7 @@ describe("applyInstallPlan", () => { await mkdir(dirname(claudeSkill), { recursive: true }); await symlink(linkTarget, claudeSkill); - await applyInstallPlan(cwd, buildInstallPlan([".claude"], [skill], []), { + await applyInstallPlan(cwd, buildInstallPlan([".claude"], [skill]), { cliVersion: "0.7.0", }); @@ -370,7 +396,7 @@ describe("applyInstallPlan", () => { await mkdir(dirname(claudeSkillDirectory), { recursive: true }); await symlink(sourceDirectory, claudeSkillDirectory); - await applyInstallPlan(cwd, buildInstallPlan([".claude"], [skill], []), { + await applyInstallPlan(cwd, buildInstallPlan([".claude"], [skill]), { cliVersion: "0.7.0", }); @@ -385,37 +411,36 @@ describe("applyInstallPlan", () => { expect(await readFile(sourceSkill, "utf8")).toBe(skill.content); }); - it("does not write through a symlinked command directory (no source clobber)", async () => { - // The command-namespace counterpart of the skill-directory case: a leftover - // `.claude/commands/tskl` symlink to source must not be written through. - const command = getEmbeddedCommands().find((c) => c.filename === "tskl.md"); - if (!command) throw new Error("embedded tskl command missing"); + it("removes a recorded command through a symlinked directory without touching source", async () => { + // A leftover `.claude/commands/tskl` symlink to source: deleting + // `tskl.md` through it would delete the source file. const sourceDirectory = join(cwd, "source", "tskl"); await mkdir(sourceDirectory, { recursive: true }); const sourceCommand = join(sourceDirectory, "tskl.md"); - await writeFile(sourceCommand, command.content, "utf8"); + await writeFile(sourceCommand, "source", "utf8"); const claudeCommandDirectory = join(cwd, ".claude", "commands", "tskl"); await mkdir(dirname(claudeCommandDirectory), { recursive: true }); await symlink(sourceDirectory, claudeCommandDirectory); + await writeInstallState(cwd, { + cliVersion: "0.12.0", + targets: { + ".claude": { + skills: ["taskless"], + commands: ["tskl.md"], + mode: "reference", + }, + }, + }); await applyInstallPlan( cwd, - buildInstallPlan([".claude"], [tasklessSkill()], [command]), - { cliVersion: "0.7.0" } + buildInstallPlan([".claude"], [tasklessSkill()]), + { cliVersion: "0.13.0" } ); - // The symlinked directory was replaced by a real one holding a shim stub... - const directoryStats = await lstat(claudeCommandDirectory); - expect(directoryStats.isSymbolicLink()).toBe(false); - expect(directoryStats.isDirectory()).toBe(true); - expect( - isShimStub( - await readFile(join(claudeCommandDirectory, "tskl.md"), "utf8") - ) - ).toBe(true); - // ...and the source file behind the former symlink is untouched. - expect(await readFile(sourceCommand, "utf8")).toBe(command.content); + expect(await exists(claudeCommandDirectory)).toBe(false); + expect(await readFile(sourceCommand, "utf8")).toBe("source"); }); it("does not touch unknown files in a skills directory", async () => { @@ -425,7 +450,7 @@ describe("applyInstallPlan", () => { }); await writeFile(userOwned, "# user skill", "utf8"); - const plan = buildInstallPlan([".claude"], [tasklessSkill()], []); + const plan = buildInstallPlan([".claude"], [tasklessSkill()]); await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); expect(await exists(userOwned)).toBe(true); diff --git a/packages/cli/test/canonical-store.test.ts b/packages/cli/test/canonical-store.test.ts index fd8a3a77..6184b41a 100644 --- a/packages/cli/test/canonical-store.test.ts +++ b/packages/cli/test/canonical-store.test.ts @@ -4,14 +4,12 @@ import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - buildCommandStub, buildSkillStub, isShimStub, stubFrontmatterDrifted, stubPredatesRecovery, stubRecoveryInvocation, stubRecoveryInvocationStale, - writeCanonicalCommand, writeCanonicalSkill, } from "../src/install/canonical"; import { parseFrontmatter } from "../src/install/frontmatter"; @@ -32,7 +30,7 @@ description: Use for any Taskless task. ${SENTINEL} — full canonical skill instructions live here. `; -describe("writeCanonicalSkill / writeCanonicalCommand", () => { +describe("writeCanonicalSkill", () => { let temporaryDirectory: string; beforeEach(async () => { @@ -56,20 +54,6 @@ describe("writeCanonicalSkill / writeCanonicalCommand", () => { expect(await readFile(path, "utf8")).toBe(skillSource); }); - it("writes command content to .taskless/commands/tskl verbatim", async () => { - const commandSource = "---\nname: Taskless\n---\n\nbody\n"; - const { path, changed } = await writeCanonicalCommand( - temporaryDirectory, - "tskl.md", - commandSource - ); - expect(changed).toBe(true); - expect(path).toBe( - join(temporaryDirectory, ".taskless", "commands", "tskl", "tskl.md") - ); - expect(await readFile(path, "utf8")).toBe(commandSource); - }); - it("reports no change when the bytes on disk already match", async () => { // Every install used to rewrite the store, so every run looked like an // upgrade to whoever read the summary. Identical bytes are not a write. @@ -152,51 +136,6 @@ describe("buildSkillStub", () => { }); }); -describe("buildCommandStub", () => { - it("passes $ARGUMENTS through and delegates to the canonical command", () => { - const stub = buildCommandStub( - { name: "Taskless", description: "Run any Taskless action." }, - "tskl.md" - ); - const { data, content } = parseFrontmatter(stub); - expect(data.name).toBe("Taskless"); - expect(content).toContain("$ARGUMENTS"); - expect(content).toContain(".taskless/commands/tskl/tskl.md"); - }); - - it("carries the same recovery instruction as a skill stub", () => { - const stub = buildCommandStub( - { name: "Taskless", description: "Run any Taskless action." }, - "tskl.md" - ); - const { content } = parseFrontmatter(stub); - expect(content).toContain("does not exist"); - expect(content).toContain(`\`${buildInvocation()} init\``); - }); - - it("preserves the canonical argument-hint when present", () => { - const stub = buildCommandStub( - { - name: "Taskless", - description: "Run any Taskless action.", - argumentHint: "", - }, - "tskl.md" - ); - const { data } = parseFrontmatter(stub); - expect(data["argument-hint"]).toBe(""); - }); - - it("omits argument-hint when the canonical command has none", () => { - const stub = buildCommandStub( - { name: "Taskless", description: "Run any Taskless action." }, - "tskl.md" - ); - const { data } = parseFrontmatter(stub); - expect(data["argument-hint"]).toBeUndefined(); - }); -}); - describe("isShimStub", () => { it("returns true for a generated skill stub", () => { expect( @@ -204,14 +143,6 @@ describe("isShimStub", () => { ).toBe(true); }); - it("returns true for a generated command stub", () => { - expect( - isShimStub( - buildCommandStub({ name: "Taskless", description: "d" }, "tskl.md") - ) - ).toBe(true); - }); - it("returns false for a full canonical skill copy", () => { // skillSource has no metadata.type — it is a full copy, not a stub. expect(isShimStub(skillSource)).toBe(false); @@ -268,11 +199,6 @@ describe("stubPredatesRecovery", () => { buildSkillStub({ name: "taskless", description: "d" }) ) ).toBe(false); - expect( - stubPredatesRecovery( - buildCommandStub({ name: "Taskless", description: "d" }, "tskl.md") - ) - ).toBe(false); }); it("returns true for a stub written before the recovery instruction", () => { @@ -312,9 +238,6 @@ describe("stubRecoveryInvocation / stubRecoveryInvocationStale", () => { expect(stubRecoveryInvocation(buildSkillStub(meta))).toBe( productionRestore ); - expect(stubRecoveryInvocation(buildCommandStub(meta, "tskl.md"))).toBe( - productionRestore - ); }); it("returns undefined for a stub that predates the recovery instruction", () => { @@ -325,9 +248,6 @@ describe("stubRecoveryInvocation / stubRecoveryInvocationStale", () => { it("leaves a stub written by this same build alone", () => { expect(stubRecoveryInvocationStale(buildSkillStub(meta))).toBe(false); - expect(stubRecoveryInvocationStale(buildCommandStub(meta, "tskl.md"))).toBe( - false - ); }); it("reclaims a stub frozen with a pinned nightly invocation", () => { diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index e58894ea..47e45695 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; import { readFile, mkdtemp, rm, mkdir } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -150,11 +151,9 @@ describe("cli", () => { ); expect(skillContent).toContain("name: taskless"); - const commandContent = await readFile( - join(temporaryDirectory, ".claude", "commands", "tskl", "tskl.md"), - "utf8" + expect(existsSync(join(temporaryDirectory, ".claude", "commands"))).toBe( + false ); - expect(commandContent).toContain("Taskless"); }); it("reports staleness via info after install", async () => { diff --git a/packages/cli/test/install.test.ts b/packages/cli/test/install.test.ts index cb464f90..85af10d5 100644 --- a/packages/cli/test/install.test.ts +++ b/packages/cli/test/install.test.ts @@ -9,7 +9,6 @@ import { checkStaleness, detectSelectedDirectories, detectTools, - getEmbeddedCommands, getEmbeddedSkills, } from "../src/install/install"; @@ -156,24 +155,18 @@ describe("detectSelectedDirectories", () => { describe("buildInstallPlan and duplicate shim rows", () => { it("plans .agents exactly once when it is selected", () => { - const plan = buildInstallPlan( - [".agents"], - getEmbeddedSkills(), - getEmbeddedCommands() - ); + const plan = buildInstallPlan([".agents"], getEmbeddedSkills()); const agentsTargets = plan.targets.filter((t) => t.dir === ".agents"); expect(agentsTargets).toHaveLength(1); // The generic row supplies the shared directory's label: the selection // carries a directory, never which of the two rows the user ticked. expect(agentsTargets[0]!.label).toBe("Agent Skills"); - expect(agentsTargets[0]!.commands).toEqual([]); }); it("plans .agents once when a caller passes it more than once", () => { const plan = buildInstallPlan( [".agents", ".agents", ".claude"], - getEmbeddedSkills(), - getEmbeddedCommands() + getEmbeddedSkills() ); expect(plan.targets.filter((t) => t.dir === ".agents")).toHaveLength(1); expect(plan.targets.filter((t) => t.dir === ".claude")).toHaveLength(1); @@ -183,7 +176,7 @@ describe("buildInstallPlan and duplicate shim rows", () => { // What a user who ticks both `Codex` and `Agent Skills` gets: the // multiselect collapses them onto one `.agents` value, and the plan must // not turn that back into two targets writing the same files twice. - const plan = buildInstallPlan([".agents"], getEmbeddedSkills(), []); + const plan = buildInstallPlan([".agents"], getEmbeddedSkills()); const result = await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); const agentsWrites = result.writtenSkills.filter( @@ -198,11 +191,7 @@ describe("buildInstallPlan and duplicate shim rows", () => { describe("checkStaleness", () => { it("reports a detected tool as up to date after install", async () => { await mkdir(join(cwd, ".claude"), { recursive: true }); - const plan = buildInstallPlan( - [".claude"], - getEmbeddedSkills(), - getEmbeddedCommands() - ); + const plan = buildInstallPlan([".claude"], getEmbeddedSkills()); await applyInstallPlan(cwd, plan, { cliVersion: "0.7.0" }); const statuses = await checkStaleness(cwd); diff --git a/packages/cli/test/installed-documentation.test.ts b/packages/cli/test/installed-documentation.test.ts index de355732..239dfc06 100644 --- a/packages/cli/test/installed-documentation.test.ts +++ b/packages/cli/test/installed-documentation.test.ts @@ -11,7 +11,6 @@ import { LATEST_SCHEMA_VERSION } from "../src/filesystem/migrate"; import { applyInstallPlan, buildInstallPlan, - getEmbeddedCommands, getEmbeddedSkills, } from "../src/install/install"; import { buildInvocation } from "../src/util/invocation"; @@ -120,23 +119,19 @@ describe("this repository's own installed Taskless docs", () => { expect(onDisk).not.toContain("sg/rules/"); }); - it("spells the CLI as %(TASKLESS_CLI)s in the skill and command sources, never the literal", async () => { + it("spells the CLI as %(TASKLESS_CLI)s in the skill source, never the literal", async () => { // The canonical write used to find `npx @taskless/cli` in the prose and // rewrite it for nightly/dev builds. A literal is whitespace-sensitive: a // wrapped line or a doubled space escaped the rewrite silently, and a // nightly install then told its agent to run the release package. The // token either matches exactly or this test says it is missing. - for (const [name, path] of [ - ["SKILL.md", resolve(repositoryRoot, "skills", "taskless", "SKILL.md")], - ["tskl.md", resolve(repositoryRoot, "commands", "tskl", "tskl.md")], - ] as const) { - const source = await readFile(path, "utf8"); - const body = source.replace(/^---\n[\s\S]*?\n---\n/, ""); - expect(body, `${name} names the CLI`).toContain("%(TASKLESS_CLI)s"); - expect(body, `${name} carries the literal`).not.toContain( - "npx @taskless/cli" - ); - } + const source = await readFile( + resolve(repositoryRoot, "skills", "taskless", "SKILL.md"), + "utf8" + ); + const body = source.replace(/^---\n[\s\S]*?\n---\n/, ""); + expect(body).toContain("%(TASKLESS_CLI)s"); + expect(body).not.toContain("npx @taskless/cli"); }); it("renders the placeholder to this build's invocation at install, and nothing else", async () => { @@ -144,16 +139,11 @@ describe("this repository's own installed Taskless docs", () => { try { await applyInstallPlan( cwd, - buildInstallPlan( - [".claude"], - getEmbeddedSkills(), - getEmbeddedCommands() - ), + buildInstallPlan([".claude"], getEmbeddedSkills()), { cliVersion: "0.0.0-test" } ); for (const relative of [ join(".taskless", "skills", "taskless", "SKILL.md"), - join(".taskless", "commands", "tskl", "tskl.md"), ]) { const installed = await readFile(join(cwd, relative), "utf8"); expect(installed, relative).toContain(`${buildInvocation()} agent`); @@ -164,26 +154,16 @@ describe("this repository's own installed Taskless docs", () => { } }); - it("tells an agent a recipe is per-task, in the skill and the command", async () => { + it("tells an agent a recipe is per-task", async () => { // A recipe is resolved at fetch time. Without this an agent that fetched // `agent check` once reuses the text for every later task in the session, - // including after the upgrade that changed it. Both always-loaded surfaces - // carry it, since a tool may install one without the other. + // including after the upgrade that changed it. const skill = await readFile( resolve(repositoryRoot, "skills", "taskless", "SKILL.md"), "utf8" ); - const command = await readFile( - resolve(repositoryRoot, "commands", "tskl", "tskl.md"), - "utf8" - ); - for (const [name, body] of [ - ["SKILL.md", skill], - ["tskl.md", command], - ] as const) { - expect(body, name).toContain("resolved when it is fetched"); - expect(body, name).toContain("earlier in this session"); - } + expect(skill).toContain("resolved when it is fetched"); + expect(skill).toContain("earlier in this session"); }); it("preflights the CLI and scopes a blocked one to the exact invocation", async () => { @@ -191,35 +171,36 @@ describe("this repository's own installed Taskless docs", () => { // the refusal lands on the first command that does work, after any // consent question that lives inside a recipe. And an agent left to pick // its own remedy proposed `npx @taskless/cli-nightly:*`, approving every - // future nightly rather than the one installed. Both always-loaded - // surfaces carry the step, since a tool may install one without the other. + // future nightly rather than the one installed. const skill = await readFile( resolve(repositoryRoot, "skills", "taskless", "SKILL.md"), "utf8" ); - const command = await readFile( - resolve(repositoryRoot, "commands", "tskl", "tskl.md"), - "utf8" + // Compared as prose, so where a line happens to wrap does not matter. + const prose = skill.replaceAll(/\s+/g, " "); + expect(prose).toContain("%(TASKLESS_CLI)s agent ok"); + expect(prose).toContain("before any other Taskless command"); + expect(prose).toContain( + "allow rule for exactly the invocation `%(TASKLESS_CLI)s`" ); - for (const [name, body] of [ - ["SKILL.md", skill], - ["tskl.md", command], - ] as const) { - // Compared as prose, so where a line happens to wrap does not matter. - const prose = body.replaceAll(/\s+/g, " "); - expect(prose, name).toContain("%(TASKLESS_CLI)s agent ok"); - expect(prose, name).toContain("before any other Taskless command"); - expect(prose, name).toContain( - "allow rule for exactly the invocation `%(TASKLESS_CLI)s`" - ); - expect(prose, name).toContain("`/permissions`"); - expect(prose, name).toContain( - "approve every Taskless command manually, here in chat" - ); - // `!` runs the preflight once and leaves every later command blocked. - expect(prose, name).not.toContain("! "); - expect(prose, name).toContain("wildcard over the package name"); - } + // Auto mode refused the agent's own settings write; it then handed the + // user a command that made the change, and that worked. + expect(prose).toContain("give the user the change to make themselves"); + expect(prose).toContain("`/permissions`"); + expect(prose).toContain("`Bash(%(TASKLESS_CLI)s:*)`"); + expect(prose).toContain( + "approve every Taskless command manually, here in chat" + ); + // `!` runs the preflight once and leaves every later command blocked. + expect(prose).not.toContain("! "); + expect(prose).toContain("wildcard over the package name"); + // Asking "what next?" while blocked, then again once unblocked, put two + // extra questions in front of onboarding's one. + expect(prose).toContain("Do not ask what they want Taskless to do"); + expect(prose).toContain("no menu"); + // "let's use taskless" names no task, and onboarding's question is the + // one that matters. + expect(prose).toContain("do not offer a menu of topics first"); }); it("keeps the skill body within its 150-line cap", async () => { diff --git a/packages/cli/test/migrated-envelope.test.ts b/packages/cli/test/migrated-envelope.test.ts index 2433b65d..c766e4c9 100644 --- a/packages/cli/test/migrated-envelope.test.ts +++ b/packages/cli/test/migrated-envelope.test.ts @@ -168,7 +168,6 @@ describe("who migrates, and who refuses", () => { dir: string; mode: string; writtenSkills: string[]; - writtenCommands: string[]; removedSkills: string[]; removedCommands: string[]; }>; @@ -184,7 +183,7 @@ describe("who migrates, and who refuses", () => { expect(byDirectory.get(".taskless")?.mode).toBe("canonical"); expect(byDirectory.get(".taskless")?.writtenSkills).toContain("taskless"); expect(byDirectory.get(".claude")?.mode).toBe("reference"); - expect(byDirectory.get(".claude")?.writtenCommands).toContain("tskl.md"); + expect(byDirectory.get(".claude")?.writtenSkills).toContain("taskless"); expect(envelope.migrated).toBeDefined(); expect(envelope.changed).toBe(true); diff --git a/packages/cli/test/nightly/skill-version-stamp.test.ts b/packages/cli/test/nightly/skill-version-stamp.test.ts index 5ba5a629..b6a4d123 100644 --- a/packages/cli/test/nightly/skill-version-stamp.test.ts +++ b/packages/cli/test/nightly/skill-version-stamp.test.ts @@ -8,7 +8,6 @@ import { applyInstallPlan, buildInstallPlan, checkStaleness, - getEmbeddedCommands, getEmbeddedSkills, } from "../../src/install/install"; import { CLI_VERSION } from "../../src/version"; @@ -41,11 +40,7 @@ describe("installing a nightly stamps the skill with the nightly's version", () }); async function install(): Promise { - const plan = buildInstallPlan( - [".claude"], - getEmbeddedSkills(), - getEmbeddedCommands() - ); + const plan = buildInstallPlan([".claude"], getEmbeddedSkills()); await applyInstallPlan(cwd, plan, { cliVersion: CLI_VERSION }); } diff --git a/packages/cli/test/nightly/stub-description-invocation.test.ts b/packages/cli/test/nightly/stub-description-invocation.test.ts index 15f3c8aa..e826cbfe 100644 --- a/packages/cli/test/nightly/stub-description-invocation.test.ts +++ b/packages/cli/test/nightly/stub-description-invocation.test.ts @@ -8,7 +8,6 @@ import { parseFrontmatter } from "../../src/install/frontmatter"; import { applyInstallPlan, buildInstallPlan, - getEmbeddedCommands, getEmbeddedSkills, } from "../../src/install/install"; import { @@ -24,8 +23,8 @@ import { * this repo's default prod define. * * taskless/cli#298: a `.claude`/`.agents` stub's frontmatter `description` is - * copied verbatim from the embedded skill/command source (see `writeSkill` / - * `writeCommand` in `src/install/install.ts`) and is NEVER passed through + * copied verbatim from the embedded skill source (see `writeSkill` in + * `src/install/install.ts`) and is NEVER passed through * `applyCliInvocation` — only a canonical `.taskless/` file gets that rewrite. * Before the fix, a description that named the CLI invocation would freeze a * stale, unpinned `npx @taskless/cli` reference into every `.claude` stub @@ -59,15 +58,12 @@ describe("installing a nightly writes a stub description that names no CLI packa const skillStubPath = () => join(cwd, ".claude", "skills", "taskless", "SKILL.md"); - const commandStubPath = () => - join(cwd, ".claude", "commands", "tskl", "tskl.md"); const canonicalSkillPath = () => join(cwd, ".taskless", "skills", "taskless", "SKILL.md"); async function install(): Promise { const skills = getEmbeddedSkills(); - const commands = getEmbeddedCommands(); - const plan = buildInstallPlan([".claude"], skills, commands); + const plan = buildInstallPlan([".claude"], skills); await applyInstallPlan(cwd, plan, { cliVersion: "0.0.0-nightly.test" }); } @@ -77,15 +73,11 @@ describe("installing a nightly writes a stub description that names no CLI packa const skillDescription = parseFrontmatter( await readFile(skillStubPath(), "utf8") ).data.description as string; - const commandDescription = parseFrontmatter( - await readFile(commandStubPath(), "utf8") - ).data.description as string; // Not the released package, and not this build's own pinned nightly // either — the fix is that description names no package at all, so there // is nothing left to go stale on a later, differently-pinned install. expect(skillDescription).not.toMatch(/@taskless\/cli/); - expect(commandDescription).not.toMatch(/@taskless\/cli/); }); it("still correctly pins the canonical file's own invocation", async () => { diff --git a/packages/cli/test/nightly/stub-recovery-invocation.test.ts b/packages/cli/test/nightly/stub-recovery-invocation.test.ts index 1cd1b4c2..d7e40ea0 100644 --- a/packages/cli/test/nightly/stub-recovery-invocation.test.ts +++ b/packages/cli/test/nightly/stub-recovery-invocation.test.ts @@ -4,7 +4,6 @@ import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { - buildCommandStub, buildSkillStub, stubRecoveryInvocation, stubRecoveryInvocationStale, @@ -12,7 +11,6 @@ import { import { applyInstallPlan, buildInstallPlan, - getEmbeddedCommands, getEmbeddedSkills, } from "../../src/install/install"; import { @@ -52,16 +50,10 @@ describe("the nightly build's own stubs", () => { it("names its own pinned package in the recovery line", () => { expect(stubRecoveryInvocation(buildSkillStub(META))).toBe(NIGHTLY_RESTORE); - expect(stubRecoveryInvocation(buildCommandStub(META, "tskl.md"))).toBe( - NIGHTLY_RESTORE - ); }); it("does not consider a stub it just wrote stale", () => { expect(stubRecoveryInvocationStale(buildSkillStub(META))).toBe(false); - expect(stubRecoveryInvocationStale(buildCommandStub(META, "tskl.md"))).toBe( - false - ); }); }); @@ -108,7 +100,7 @@ describe("installing a nightly over an existing stub", () => { async function install(): Promise { const skills = getEmbeddedSkills().filter((s) => s.name === "taskless"); - const plan = buildInstallPlan([".claude"], skills, getEmbeddedCommands()); + const plan = buildInstallPlan([".claude"], skills); await applyInstallPlan(cwd, plan, { cliVersion: "0.0.0-nightly.test" }); } diff --git a/packages/cli/test/stub-description-invocation.test.ts b/packages/cli/test/stub-description-invocation.test.ts index b5d85503..92199116 100644 --- a/packages/cli/test/stub-description-invocation.test.ts +++ b/packages/cli/test/stub-description-invocation.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import { getEmbeddedCommands, getEmbeddedSkills } from "../src/install/install"; +import { getEmbeddedSkills } from "../src/install/install"; /** * Regression coverage for taskless/cli#298. * - * `getEmbeddedSkills`/`getEmbeddedCommands` read `name`/`description` straight - * out of a skill or command's raw frontmatter — the exact text later copied + * `getEmbeddedSkills` reads `name`/`description` straight + * out of a skill's raw frontmatter — the exact text later copied * verbatim into a `.claude`/`.agents`/etc. reference stub's own frontmatter * (see `writeSkill`/`writeCommand` in `src/install/install.ts`, which pass * `skill.description`/`command.description` through unchanged as @@ -51,13 +51,4 @@ describe("skill/command descriptions never name the CLI package", () => { ).not.toMatch(NAMES_THE_CLI_PACKAGE); } }); - - it("no embedded command's description carries a CLI invocation", () => { - for (const command of getEmbeddedCommands()) { - expect( - command.description, - `command "${command.filename}" description names the CLI package` - ).not.toMatch(NAMES_THE_CLI_PACKAGE); - } - }); }); diff --git a/skills/taskless/SKILL.md b/skills/taskless/SKILL.md index 6576856e..c4b79c06 100644 --- a/skills/taskless/SKILL.md +++ b/skills/taskless/SKILL.md @@ -23,7 +23,6 @@ description: | metadata: author: taskless version: 0.12.0 - commandName: tskl compatibility: Designed for Agents implementing the Agent Skills specification. --- @@ -38,24 +37,35 @@ each CLI version. Once per session, before any other Taskless command, run `%(TASKLESS_CLI)s agent ok`. It touches no project files, and prints one line -confirming the invocation works. Proceed once you see it. +confirming the invocation works. Once you see it, go straight to the user's +request: no survey of the install, and no menu of what Taskless can do. If the harness or the user blocks it, or blocks any later Taskless command, stop before doing any Taskless work and do not try to get around the block. -Tell the user the exact command you ran, and that it downloads and runs a -package from npm. Then offer these three ways forward, and wait for their -choice: +Send one message that names the exact command you ran, says it downloads and +runs a package from npm, and offers three ways forward: - You add an allow rule for exactly the invocation `%(TASKLESS_CLI)s` to the - agent's permission settings. -- They approve it through their agent's permissions command, such as - `/permissions` in Claude Code. + agent's permission settings. If the harness blocks that edit too, give the + user the change to make themselves, in whatever form your harness accepts. +- They approve it through their agent's permissions command. In Claude Code + that is `/permissions`, and the rule is `Bash(%(TASKLESS_CLI)s:*)`. - They approve every Taskless command manually, here in chat, as you run it. Never propose a rule wider than that invocation, such as a wildcard over the package name or its version: it would approve a CLI this project never installed. +Do not ask what they want Taskless to do in that message. Their request +stands: once they unblock the command, rerun `agent ok` and carry it out. + +## A request that names no task + +When the user asks to use, try, or set up Taskless without naming a task, +fetch `%(TASKLESS_CLI)s agent onboard` and follow it. Its one question is the +only one to ask, so do not offer a menu of topics first. If onboarding +already ran, the recipe says so, and then you ask what they want. + ## Authoring a rule: always start at route For any request to add/write/create a rule — whether or not the user names a