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
19 changes: 19 additions & 0 deletions .changeset/rules-reconciliation-ledger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@taskless/cli": minor
---

`taskless update` now tells an agent what an upgrade changed for the rules already in a project, and records when that work is done.

**BREAKING for anyone scripting `taskless update`.** It used to mean "reinstall the skills non-interactively", which is what `taskless init --no-interactive` already does through the same code path, and what running `taskless` does on its own. Scripts relying on the old behavior should call `taskless init --no-interactive`.

The word is reclaimed for the job an agent actually needs. Running the CLI migrates the `.taskless/` layout and refreshes skills: that is the directory, and it is automatic. No migration can rewrite the rules themselves, and a rewriter that now requires a `fix`, or a rule whose matching semantics shifted under a new engine, is a question about content. An agent that has run a migration and watched it succeed will otherwise reasonably conclude the upgrade is finished.

`taskless update` with no flags serves a ledger: one section per release, in order, saying what that version means for existing rules. Sections are cumulative, and a version with nothing to do says so explicitly, because an agent cannot tell "nothing here" from "nobody wrote this". The first entry covers 0.11.0: the newly required `fix` on rewriters, Markdown's block-only grammar and its two opposite failure shapes, `sg run --lang` accepting alias spellings, and the matching-semantics changes that alter what a valid rule matches with no error at all.

`taskless update --reconciledTo=<version>` records that the walk finished, in a new `rules` section of `.taskless/taskless.json` alongside the existing `install`. It stores the CLI version and the ast-grep and Vale versions the rules are now valid against.

The two namespaces are separate because they drift. `install` records how the scaffold got here and moves on a skills refresh; `rules` records what the rules are valid against and moves only on a completed reconciliation. Keying rule work off `install.cliVersion` would let an agent skip entries it never performed, and it would fail quietly: the walk would report nothing to do while the rules stayed wrong.

The version is validated rather than trusted. A value ahead of the installed CLI is rejected, since this build carries no entries for it, and the marker is never moved backwards.

`taskless info --json` reports both namespaces, so an agent reads where to start from the same payload it already fetches.
152 changes: 117 additions & 35 deletions packages/cli/src/agent/update.txt
Original file line number Diff line number Diff line change
@@ -1,48 +1,130 @@
# Topic: update (CLI v%(CLI_VERSION)s / topic v1)
# Topic: update (CLI v%(CLI_VERSION)s / topic v2)

## Goal
Update Taskless skills in the user's coding-agent tools to the
latest bundled version. Non-interactive, no wizard, no prompts.
Installs to all detected tool locations using the same logic as
`%(TASKLESS_CLI)s init --no-interactive`, but exposed as its own subcommand
so the agent can run it directly without explaining flags.
## You are here
This is `update`. It tells you what an upgrade changed for the rules
ALREADY IN THIS PROJECT, and what to do about them.

It is not about installing. Running `%(TASKLESS_CLI)s` migrates the
`.taskless/` layout and refreshes the installed skills on its own. That
handles the DIRECTORY. This recipe handles the RULES, which no migration
can rewrite for you: a rewriter that now needs a `fix`, a rule whose
matching semantics shifted under a new engine, a rule that could now be
expressed better with a newly supported language.

An agent that has run a migration and watched it succeed will otherwise
reasonably conclude the upgrade is done. It is not. The directory is
migrated; the rules may still need work.

This is the right command when the user has Taskless already
installed and just wants to refresh to a new version (e.g. after
running `%(TASKLESS_CLI)s`).
## Goal
Walk the ledger below from the version this project was last reconciled
to, up to the installed CLI, doing what each section says. Then record
that you finished.

## Preconditions
- None at the user level. Works in any directory.
- For tool detection: at least one of `.claude/`, `.opencode/`,
`.cursor/`, or a related marker file exists. Otherwise installs
to `.agents/skills/` as a fallback.
- `.taskless/` exists. If it does not, there are no rules to reconcile.
- You can read `.taskless/taskless.json`.

## Steps

```
%(TASKLESS_CLI)s update
```

The CLI:
1. Detects installed tools (Claude Code, OpenCode, Cursor, etc.)
2. Reads the previous install state from `.taskless/taskless.json`
3. Computes the diff (skills/commands to add, remove)
4. Rewrites the canonical `taskless` skill and `tskl` command in
`.taskless/`, and refreshes the reference stub in each detected
tool directory
5. Converts any stale full copies or symlinks left by older installs
into stubs, and removes obsolete files from prior versions
6. Updates the install manifest
7. Prints a summary including what was written and removed
1. **Find where to start.** Run:
```
%(TASKLESS_CLI)s info --json
```
and read `rules.reconciledTo`, plus `rules.engines.sg` and
`rules.engines.vale` for the engine versions the rules were built
against.

**No `rules.reconciledTo` at all** means this project has never
recorded a reconciliation. That is NOT the same as being behind. A
project created at the installed version has no history to walk, so
do nothing except step 4. Only walk sections if you can see an
earlier version to walk FROM.

2. **Walk forward, in order.** Start at the section after
`reconciledTo` and continue to the installed version. Sections are
cumulative, never replaceable: a later one can depend on an earlier
one having run, so do not skip ahead to the newest.

3. **Do the work each section names.** Each says what changed and what
it means for existing rules. A section that says there is nothing to
do means exactly that; it is a claim, not an oversight.

4. **Record that you finished.** Run:
```
%(TASKLESS_CLI)s update --reconciledTo=<installed version>
```
Only after the walk is complete. A partial or abandoned walk must not
be recorded: the next agent will start from what you wrote and skip
everything you did not do, and it will look like there was nothing to
do. The CLI rejects a version it has no entries for, and refuses to
move the marker backwards, but it cannot tell whether you actually
read the sections.

This also records the engine versions the rules are now valid
against, which is what lets a later upgrade say what moved.

## The ledger

### Migrating to 0.11.0

The vendored ast-grep moves from 0.41.0 to 0.45.2, and Vale is at
3.18.0. Four things follow for existing rules.

**A rewriter now requires `fix`.** `SerializableRewriter.required` goes
from `["rule","id"]` to `["id","fix","rule"]`, so a `rewriters:` entry
without a `fix:` is now rejected where it was accepted before.

Run `%(TASKLESS_CLI)s verify` and it names the offending rewriter
directly. It cannot be auto-fixed: `fix` is replacement text, which is
authorial intent. A rewriter with no `fix` could never have done
anything, so this surfaces a rule that was already dead rather than
breaking one that worked.

**Markdown is now a language, with a narrow shape.** `language:
Markdown` parses. What it sees is the BLOCK tree only: `document`,
`section`, `atx_heading`, `setext_heading`, `fenced_code_block`,
`list_item`, `paragraph`. Everything inside a line collapses into one
opaque `inline` node, so there is no `link`, no `emphasis`, no
`strong_emphasis`.

Two failure shapes, and they are opposites. `kind: link` is a HARD
CONFIG ERROR: exit 8, `Kind \`link\` is invalid`, which aborts config
parsing and takes every other rule's report down with it. A pattern like
`[$T]($U)` is the quiet one: it parses, runs, exits 0, and matches
nothing forever.

So "no bare URLs" and "link text must not say click here" are not
ast-grep rules even now. Use Vale, which reads prose, and note that Vale
DOES see YAML frontmatter: with `scope: raw` it sees the keys too, and
`extends: occurrence` with `min: 1` can require a field to be present.

**`sg run --lang` accepts the alias spellings.** At 0.41.0 `--lang C++`
was rejected while `language: C++` parsed. At 0.45.2 both are accepted.
This affects the flag only, not a rule's `language:` field, so no rule
file needs changing. It is recorded because the divergence used to be
documented as a thing to work around.

**Matching semantics moved, and this is the part you cannot check
locally.** Metavariable bindings no longer leak across `nthChild`
siblings, nor out of a negated `not`, and root metavariables now match
comments. A valid, unchanged rule can match a DIFFERENT SET OF NODES
with no error and no warning.

There is no differential tooling yet. If a rule uses `nthChild`, a
negated `not`, or a bare root metavariable, re-run its fixtures and read
the findings rather than the exit code: the rule can still pass its
tests while reporting differently on real code.

## Errors

`update` is non-interactive. On success it exits 0 and prints a
human-readable summary of skills/commands added, removed, or kept in
sync (same surface as `init --no-interactive`). On failure it exits
non-zero with the error message on stderr.
With `--json`, `--reconciledTo` failures emit `{ ok: false, code, message }`:

| code | meaning | fix |
|-----------------|--------------------------------------------------|------------------------------------------|
| `INVALID_INPUT` | version is ahead of this CLI, or moves backwards | reconcile to the installed version |

## See Also

- `%(TASKLESS_CLI)s agent init`: interactive variant (wizard with prompts)
- `%(TASKLESS_CLI)s agent info`: verify what's installed and check staleness
- `%(TASKLESS_CLI)s agent check`: run every engine over the repo
- `%(TASKLESS_CLI)s agent improve-rule`: rewrite a rule the walk flagged
- `%(TASKLESS_CLI)s agent info`: what is installed, and staleness
25 changes: 23 additions & 2 deletions packages/cli/src/commands/info.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { resolve } from "node:path";
import { join, resolve } from "node:path";
import { defineCommand } from "citty";

import { checkStaleness } from "../install/install";
Expand All @@ -7,6 +7,8 @@ import { fetchWhoami } from "../auth/whoami";
import { outputSchema as infoOutputSchema } from "../schemas/info";
import { makeErrorEnvelope } from "../types/errors";
import { resolveRepositoryContext } from "../util/git-remote";
import { readManifest } from "../filesystem/migrate";
import { TASKLESS_DIRECTORY } from "../rules/vale/formats";

export const infoCommand = defineCommand({
meta: {
Expand Down Expand Up @@ -36,10 +38,17 @@ export const infoCommand = defineCommand({
// The repository context resolves regardless of `--anonymous`: it comes
// from the local git remote, not from the API, so suppressing it would
// hide capability state that has nothing to do with the auth probe.
const [tools, token, repository] = await Promise.all([
const [tools, token, repository, manifest] = await Promise.all([
checkStaleness(cwd),
args.anonymous ? Promise.resolve() : getToken(cwd),
resolveRepositoryContext(cwd),
// Never fails: an absent or unreadable manifest is an ordinary state for
// a project that has not been initialised, and `info` still has plenty
// to report about one.
readManifest(join(cwd, TASKLESS_DIRECTORY)).then(
(read) => read.manifest,
() => null
),
]);

let auth: { user: string; email?: string; orgs: string[] } | undefined;
Expand All @@ -66,6 +75,18 @@ export const infoCommand = defineCommand({
// payload already; these fields ride along on a call it makes anyway.
repositoryUrl: repository.repositoryUrl,
ghOwner: repository.ghOwner,
// Two namespaces, reported separately because they answer different
// questions and drift apart. `install` is how the scaffold got here.
// `rules` is what the rules are valid against, and it moves only when a
// reconciliation is recorded.
install: { cliVersion: manifest?.install?.cliVersion ?? null },
rules: {
reconciledTo: manifest?.rules?.reconciledTo ?? null,
engines: {
sg: manifest?.rules?.engines?.sg ?? null,
vale: manifest?.rules?.engines?.vale ?? null,
},
},
};

if (args.json) {
Expand Down
121 changes: 111 additions & 10 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { resolve } from "node:path";
import { join, resolve } from "node:path";
import { defineCommand } from "citty";

import { ensureTasklessDirectory } from "../filesystem/directory";
Expand All @@ -17,6 +17,19 @@ import { runWizard } from "../wizard";
import { getCliVersion } from "../wizard/intro";

import { getOnboardTrailer } from "./onboard";
import { getRecipe } from "../prompts/recipes";
import {
detectCliInvocation,
processLauncherContext,
} from "../util/package-manager";
import {
recordReconciliation,
reconciliationStart,
} from "../rules/reconcile-marker";
import { readManifest } from "../filesystem/migrate";
import { TASKLESS_DIRECTORY } from "../rules/vale/formats";
import { CLIError } from "../util/cli-error";
import { makeErrorEnvelope } from "../types/errors";

function shouldRunInteractively(noInteractiveFlag: boolean): boolean {
if (noInteractiveFlag) return false;
Expand Down Expand Up @@ -79,18 +92,43 @@ export const initCommand = defineCommand({
},
});

/**
* `update` is about the RULES, not about the installation.
*
* It used to mean "reinstall the skills non-interactively", which is what
* `init --no-interactive` already does through the very same
* `runNonInteractive`, and what the wizard does on any ordinary run. A second
* name for that bought nothing, and it held a word that describes the job an
* agent actually needs: deciding whether the rules in front of it need
* rewriting after an engine or CLI upgrade.
*
* With no flags it serves the ledger recipe, so `taskless update` and
* `taskless agent update` are the same thing. With `--reconciledTo` it records
* that a walk completed.
*/
export const updateCommand = defineCommand({
meta: {
name: "update",
description:
"Update Taskless skills in detected tools (non-interactive install)",
"Learn what an upgrade changed for existing rules, or record a completed reconciliation",
},
args: {
dir: {
type: "string",
alias: "d",
description: "Working directory",
},
reconciledTo: {
type: "string",
description:
"Record that the ledger walk completed up to this CLI version",
},
json: {
type: "boolean",
description:
"Output as JSON: the recipe plus where the walk starts, or the recorded result with --reconciledTo",
default: false,
},
anonymous: {
type: "boolean",
description: "Accepted for compatibility; update has no auth dependency",
Expand All @@ -99,17 +137,80 @@ export const updateCommand = defineCommand({
},
async run({ args }) {
const cwd = resolve(args.dir ?? process.cwd());
const telemetry = await getTelemetry(cwd);

let success = false;
// No `--reconciledTo`: this is the teaching path. Serve the SAME recipe
// `agent update` serves, from the same renderer, so the two spellings
// cannot drift into two different sets of instructions.
if (args.reconciledTo === undefined) {
const telemetry = await getTelemetry(cwd);
const recipe = getRecipe("update", {
anonymous: args.anonymous,
invocation: detectCliInvocation(processLauncherContext()),
});
if (recipe === undefined) {
console.error("No `update` recipe is bundled with this CLI.");
process.exitCode = 1;
return;
}
telemetry.capture("cli_agent", { topic: "update" });

// `--json` is honoured here too. It used to be read only on the
// recording path, so `taskless update --json` printed plain prose and
// gave no sign the flag had done nothing.
//
// The payload also carries where the walk should START, computed by the
// CLI rather than reasoned out of the recipe's prose. Same argument as
// `route` reading `ghOwner` from `info` instead of shelling out to git:
// two places deriving one answer can disagree, and the one that acts on
// it should not be the one guessing.
if (args.json) {
const { manifest } = await readManifest(
join(cwd, TASKLESS_DIRECTORY)
).catch(() => ({ manifest: undefined }));
const walk = reconciliationStart(manifest?.rules?.reconciledTo);
console.log(
JSON.stringify({
ok: true,
topic: "update",
reconciledTo: manifest?.rules?.reconciledTo ?? null,
installed: getCliVersion(),
// `null` when there is nothing to walk: either the project has
// never recorded a reconciliation, which is not the same as being
// behind, or it is already current.
walk: walk ?? null,
recipe,
})
);
return;
}

console.log(recipe.trimEnd());
return;
}
Comment thread
thecodedrift marked this conversation as resolved.

const telemetry = await getTelemetry(cwd);
try {
await runNonInteractive(cwd);
success = true;
} finally {
// Concrete state event: skills/commands were installed/updated.
if (success) {
telemetry.capture("cli_installed");
const result = await recordReconciliation(cwd, args.reconciledTo);
if (args.json) {
console.log(JSON.stringify({ ok: true, ...result }));
} else {
console.log(
result.previous === undefined
? `Recorded: rules reconciled to ${result.reconciledTo} (ast-grep ${result.engines.sg}, Vale ${result.engines.vale}).`
: `Recorded: rules reconciled to ${result.reconciledTo}, was ${result.previous} (ast-grep ${result.engines.sg}, Vale ${result.engines.vale}).`
);
}
telemetry.capture("cli_rules_reconciled");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const code =
error instanceof CLIError && error.code ? error.code : "INTERNAL_ERROR";
if (args.json) {
console.log(JSON.stringify(makeErrorEnvelope(code, message)));
} else {
console.error(message);
}
process.exitCode = 1;
}
},
});
Expand Down
Loading
Loading