Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/init-upgrade-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@taskless/cli": patch
---

`taskless init` is now the batch install in every context, and `--no-interactive` is dropped: a bare `npx @taskless/cli` in a terminal is the wizard, `init` is the install and upgrade path for agents, scripts, and CI, and `agent init` is the recipe. A script that still passes the flag gets `init` unchanged. `init` ends with an upgrade trailer naming the directories that hold changed files and, after a CLI version move, pointing at `taskless update`; the `--json` envelope gains `cliVersion`, a per-target `targets` summary, and a `changed` flag. A canonical `.taskless/` file whose bytes already match the bundle is no longer rewritten or reported as written. The `agent` subcommand serves every recipe under a fetch-time directive (fetch again next task; a session that installed or upgraded Taskless holds a stale skill), which the `@taskless/cli/prompts` export does not carry and which `header: false` strips with the version. The `agent init` recipe is rewritten for the agent that runs it, and the skill and `tskl` command say a recipe is fetched again for each task. The skill and command sources name the CLI through the `%(TASKLESS_CLI)s` placeholder, rendered at install, so a nightly or dev build no longer depends on finding the literal `npx @taskless/cli` in prose.
30 changes: 17 additions & 13 deletions commands/tskl/tskl.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,27 @@ with Taskless before proceeding.
Otherwise, follow the same flow as the `taskless` skill:

1. Identify the topic from `$ARGUMENTS` using the table below.
2. Fetch the canonical recipe with `npx @taskless/cli agent <topic>` (or
`npx @taskless/cli agent <topic> --anonymous` if the user is offline or
2. Fetch the canonical recipe with `%(TASKLESS_CLI)s agent <topic>` (or
`%(TASKLESS_CLI)s agent <topic> --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 `npx @taskless/cli update` |
| Create a new rule | `npx @taskless/cli agent route` |
| Improve an existing rule | `npx @taskless/cli agent improve-rule` |
| Delete a rule | `npx @taskless/cli agent delete-rule` |
| Check code against rules | `npx @taskless/cli agent check` |
| Log in, log out, or status | `npx @taskless/cli agent auth` |
| Wire into CI | `npx @taskless/cli agent ci` |

If unsure, run `npx @taskless/cli agent` (no args) for the topic
| 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` |

If unsure, run `%(TASKLESS_CLI)s agent` (no args) for the topic
disambiguation table.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-10
67 changes: 67 additions & 0 deletions openspec/changes/archive/2026-09-10-init-upgrade-context/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
## Context

`taskless init` is the install path an agent takes, and until now the same word also launched the wizard when a TTY happened to be attached, with `--no-interactive` to force the batch path. The batch path ends with a per-target summary, a migration notice on stderr, a reload banner when the version moved, and the onboarding trailer. Under `--json` it prints `{ success, commandsInstalled, migrated? }`. The `agent init` recipe addresses a human. Recipes open with a one-line `# Topic:` header that `PromptOptions.header: false` strips. The skill body tells an agent to fetch a recipe before acting but says nothing about fetching it again.

## Goals / Non-Goals

**Goals**

- One spelling per reader: bare invocation for a person (wizard), `init` for an agent or script (batch, always), `agent init` for the recipe that explains the second to the first.
- An agent that ran `init` can tell, from stdout or the envelope alone, that files changed, where, and whether `update` is the next step.
- The `agent init` recipe reads as instructions for the agent that runs the command.
- Everything the `agent` subcommand serves says it is not reusable across tasks; the skill says the same.

**Non-Goals**

- Committing on the agent's behalf. `init` reports, and the recipe tells the agent to tell the user; nobody runs git.
- Reworking the reload banner or the onboarding trailer's wording.
- Changing the interactive wizard's output. Its reader is a person watching a terminal.
- A general "session state" mechanism for recipes. The directive is prose.

## Decisions

### `init` is always the batch install, and `--no-interactive` is gone

The flag existed so that a TTY could still get the batch path. With the wizard reachable only from a bare invocation, `init` has one behaviour in every context and the flag has nothing left to select. It is removed from the command definition rather than kept as a documented no-op: citty passes an undefined flag through, so a script that still spells it out gets `init` unchanged, and there is no second flag to explain. The bare TTY invocation in `index.ts` calls `runWizard` directly instead of delegating to `initCommand`. The `CI` guard `init` used to carry moves with it, as a pure `shouldLaunchWizard` so it can be tested: a spawned CLI is never on a TTY, which is exactly the case the guard exists for (a pseudo-terminal in CI with nobody behind it).

### The upgrade trailer prints first, directly after the summary

A reload, and anything the onboarding line proposes, come after the upgrade is understood: a reload is required before onboarding can use the new skill, and the commit obligation exists whether or not the user onboards. So the trailer is the first of the trailing notices, before the reload banner. The onboarding trailer stays the final line, which the existing requirement and its six scenarios pin.

### The trailer's `update` pointer names the launcher the person used

`getCliPrefix()`, not `buildInvocation()`: a message a person reads follows the convention every other remedy in the CLI uses (`auth login`, `rule delete`), which detects the real launcher and falls back to `npx`. The migration refusal in `migrate.ts` predates that convention and still uses the build's fixed spelling; it is left alone here as out of scope.

### Changed directories are named, not files

The install result reports skill and command names per target, not paths, and deriving paths would re-implement the layout the install module already knows. The trailer names target directories (`.claude/`, `.taskless/`) plus `.taskless/` when a migration ran or the version moved, which is what a `git add` needs. Migration file paths are already listed in the migration notice, and on the envelope under `migrated.files`.

### The envelope mirrors the human summary rather than a new shape

`targets` on the envelope is the per-target summary with the same fields the human path prints, which closes the gap the existing code comment describes ("this per-target summary is not on that envelope, so rather than drop it, it goes to stderr"). `cliVersion.previous` is `null` rather than absent when nothing was recorded, so a consumer can distinguish "fresh project" by value. `changed` is derivable from the other fields and is included anyway: it is the single value an agent gates its next step on, and asking each consumer to fold three lists, a presence check, and a version comparison is how one of them gets it wrong.

### A version move is a change; an identical canonical write is not

The version-moved test is the reload banner's (`previousCliVersion !== undefined && previousCliVersion !== cliVersion`), so the banner and the `update` pointer never disagree. A move rewrites `install.cliVersion` in `taskless.json`, a tracked file, so it counts even when no skill byte changed.

The opposite case needed fixing too. The canonical store was rewritten on every install and reported as written every time, so a no-op re-install looked like an upgrade in the summary and would have fired the trailer. `writeCanonicalSkill`/`writeCanonicalCommand` now compare bytes and return `{ path, changed }`.

### The directive is rendered on the `agent` command's request, not written into recipe files

Twenty recipe files carrying an identical line invited twenty paraphrases, and the line would be false for a consumer reading a recipe through `@taskless/cli/prompts`, which has no CLI to re-run. So `RecipeOptions.directive` (default `false`) inserts it as line 2 of the header block, `commands/agent.ts` and the `update` command pass `true`, and the prompts export never does. The invocation in the directive comes from the same `resolveInvocation` that renders `%(TASKLESS_CLI)s`, so the command an agent is told to re-run is the one that served it. It also says a session that installed or upgraded Taskless holds a stale skill, since the recipe is the one text such a session receives fresh.

Living in the header block means `stripHeader` drops it with the version: the function now strips through the first blank line rather than a fixed line count, with the same first-line `# Topic:` anchor so a header inside a fenced example is left alone. Parity between the export and the command is stated modulo that option, and a consumer that passes `directive: true` gets exactly what the command prints.

### The skill and command name the CLI through a placeholder

The canonical write used to find the literal `npx @taskless/cli` in the skill and command prose and rewrite it to the build's invocation. That search is whitespace-sensitive: a wrapped line or a doubled space left the literal in place, and a nightly install then carried a skill telling its agent to run the release package, with nothing failing. The sources now spell the invocation as `%(TASKLESS_CLI)s`, the recipes' token, and `renderInvocationPlaceholder` substitutes it exactly at install. It is an exact-token replacement rather than a sprintf render, since the sources are prose with no other placeholders and sprintf would turn a literal `%` in a markdown body into a render error. A source test asserts the literal is absent and the token present, and an install test asserts the rendered file carries the build's invocation and no unrendered `%(`. The token is the same width as `npx @taskless/cli`, so the topic tables stay aligned in source and in a release install. `applyCliInvocation` stays for one-line code strings, where an exact literal is not brittle.

### The skill directive is a section, budgeted against the cap

The body is 72 lines against an 80-line cap. The directive is a heading and five lines. The `tskl` command has no cap and gets the same paragraph.

## Risks / Trade-offs

- **`taskless init` in a TTY no longer prompts.** That is the one behaviour a person can notice, and it is the point: `init` means the same thing everywhere. The README and the non-TTY preamble both name the bare invocation for the wizard.
- **Tests that pin `init --json` to an exact object** fail on the new fields. Those are updated to assert the fields they care about, and the new fields get their own assertions. No consumer parses `init --json` outside this repository's tests today.
- **A recipe rendered with `header: false` loses the directive.** That is the intent: that rendering is for a consumer embedding the text in its own prompt, where "re-run the CLI" is the wrong instruction.
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
## Why

An agent that runs `taskless init`, usually because `check` refused a project whose scaffold is behind the CLI, is told what was written and then pointed at onboarding. Nothing tells it that the rewrite touched files under version control and belongs in its commit, or that an upgrade of the CLI is the moment `taskless update` exists for. Separately, nothing in the skill or in a recipe says a recipe is resolved at fetch time, so an agent that fetched `agent check` once in a session reuses that text for every later task in the same session, including after the very upgrade that changed it.

Both gaps are in agent-facing text and output shape that ship in the bundle, so they land together as one change.

## What Changes

- `taskless init` is the batch install in every context, and `--no-interactive` is dropped. Three spellings, three readers: `npx @taskless/cli` is the wizard for a person in a terminal; `npx @taskless/cli init` is the install and upgrade path for an agent, a script, and CI, with no flag; `npx @taskless/cli agent init` is the recipe that tells an agent how to do it. A script that still passes `--no-interactive` gets `init` unchanged, since citty passes an undefined flag through.
- `taskless init` reports an upgrade's consequences, not only its writes. On the human path, directly after the install summary and before the reload banner and the onboarding trailer, it prints an upgrade trailer whenever the run changed anything: which directories now hold changed files and that they belong in the next commit, and, when the recorded CLI version moved, that `taskless update` reports what the upgrade means for existing rules. Under `--json`, the envelope carries the same facts as fields: `cliVersion: { previous, installed }`, a per-target `targets` list of what was written and removed (the summary that today goes only to stderr), and a `changed` boolean.
- A canonical `.taskless/` file whose bytes already match the bundle is no longer rewritten or reported as written, so a no-op re-install reads as one.
- The `agent init` recipe is rewritten for its actual reader. Today it says "the user runs this themselves"; its most common caller is an agent that `check` just sent there. It describes `init`, the envelope, and what follows an install: tell the user which paths need committing (the agent does not run git), run `update` after a version move, and treat a session that predates the install as holding stale skills.
- The `agent` subcommand serves every recipe under a fetch-time directive, added by the renderer as the header block's second line: the text was resolved at fetch time, the next task fetches it again, and a session that installed or upgraded Taskless holds a stale skill until reloaded. The recipe files do not carry it, and neither does the `@taskless/cli/prompts` export, whose consumer has no CLI to re-run. `header: false` strips the block whole.
- The skill body and the `tskl` command carry the same directive: fetch the recipe for every Taskless task, even one already fetched earlier in the session. Both sources spell the CLI as `%(TASKLESS_CLI)s`, rendered at install to the build's invocation, in place of a whitespace-sensitive search for the literal `npx @taskless/cli` in prose.

Nothing here is **BREAKING**. `init` without a TTY already ran the batch install; with a TTY it ran the wizard, and that spelling now runs the batch install too, which is the one behaviour change a person can notice. The `init --json` envelope only gains fields; the human output only gains lines before an existing final line; recipe bodies are unchanged.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `cli-init`: a new requirement for the upgrade trailer and the `--json` fields it mirrors; `init` becomes the batch path and the bare invocation the wizard; the canonical-rewrite requirement says an unchanged file is not reported.
- `cli-agent`: a new requirement for the directive the `agent` subcommand serves each recipe under.
- `cli-knowledge-prompts`: parity between the export and the command is stated modulo the `directive` option.
- `skill-taskless`: the skill body includes a re-fetch directive.

## Impact

- `packages/cli/src/commands/init.ts` and `src/index.ts`: `init` always batch, the bare TTY invocation calls the wizard directly; trailer and envelope fields.
- `packages/cli/src/install/canonical.ts`: byte comparison before a canonical write.
- `packages/cli/src/agent/init.md`: rewritten and bumped to topic v2.
- `packages/cli/src/prompts/recipes.ts`: the `directive` option and the header block stripping.
- `skills/taskless/SKILL.md`, `commands/tskl/tskl.md`: the directive.
- Tests under `packages/cli/test/` for each of the above.
- A `patch` changeset: the package is `0.y.z`, and nothing here is something a consumer must react to.

Delivery shape: **single PR**. The diff is agent-facing prose, one command's output, and their tests, and it does not depend on any other open branch.
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
## ADDED Requirements

### Requirement: The agent subcommand serves each recipe under a fetch-time header

When `taskless agent <topic>` serves a recipe, the rendered text SHALL carry a fetch-time directive as the second line of its header block, directly beneath the `# Topic:` line and before the blank line that closes the block. The directive SHALL state that the CLI resolved the text at the moment it was fetched, that the next Taskless task fetches it again with `<invocation> agent <topic>` rather than reusing this copy, and that a session in which Taskless was installed or upgraded holds a stale skill until it is reloaded. It SHALL be identical for every topic except for the rendered invocation.

The directive is added by the renderer on the `agent` command's request, not written into the recipe files. A recipe file SHALL keep its single-line `# Topic:` header, and a prompt rendered through `@taskless/cli/prompts` SHALL NOT carry the directive: that export exists for a consumer embedding the text in its own prompt, where an instruction to re-run a CLI may be false. `PromptOptions.header: false` SHALL strip the whole header block, directive included, whenever one is present.

#### Scenario: A served recipe opens with the directive

- **WHEN** `taskless agent check` is run
- **THEN** line 1 of stdout SHALL be the `# Topic: check …` header
- **AND** line 2 SHALL be the fetch-time directive, naming the invocation and `agent <topic>`
- **AND** line 3 SHALL be blank, followed by the recipe body unchanged

#### Scenario: The prompts export does not carry the directive

- **WHEN** a consumer renders any topic through `@taskless/cli/prompts` with default options
- **THEN** the text SHALL begin with the `# Topic:` line followed directly by a blank line
- **AND** SHALL NOT contain the directive

#### Scenario: Header suppression removes the directive with the version

- **WHEN** a recipe is rendered with the directive and `header: false`
- **THEN** the result SHALL contain neither the `# Topic:` line, the CLI version, nor the directive
- **AND** its body SHALL be byte-identical to the body of the default rendering
Loading
Loading