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
11 changes: 11 additions & 0 deletions .changeset/rule-meta-cannot-succeed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@taskless/cli": patch
---

`taskless rule meta` now says why it has no data, instead of reporting the rule as missing.

The `.taskless/rule-metadata/<id>.yml` sidecar is written from the `meta` block of a rule status response, and the rule service does not populate that block. No sidecar has ever been written, so `rule meta` failed with `RULE_NOT_FOUND` for every id, including rules plainly on disk. It now fails with the new `RULE_META_UNAVAILABLE` code and an explanation, and points at the ticket id that actually drives iteration.

`rule improve` now reports `RULE_NOT_FOUND` when the service has no such ticket id, instead of folding that 404 into `NETWORK_ERROR`. The two ask for different things: one says re-check the id, the other says retry.

The `improve-rule` recipe no longer routes through `rule meta`. It takes the ticket id from the `ruleId` field of `rule create --json`, which is the id the iterate endpoint is addressed by, and falls back to the local-only flow when nobody has it. `rule-meta`, `rule`, `delete-rule`, `create-remote-rule`, and `create-sg-rule` were corrected where they described the sidecar as something that exists.
4 changes: 3 additions & 1 deletion openspec/specs/cli-rules/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,9 @@ When `taskless rule create --anonymous` is invoked, the CLI SHALL execute the lo
2. Generate the ast-grep rule using local logic (Claude SDK, agent-driven generation, or whatever the migrated implementation prefers — see design.md)
3. Write the rule file to `.taskless/rules/sg/<id>/<id>.yml`
4. Write any generated test files into that rule's own directory, under `.taskless/rules/sg/<id>/.tests/`
5. NOT write a metadata sidecar (the API-backed branch does)
5. NOT write a metadata sidecar (neither does the API-backed branch: the
service never populates the `meta` block a sidecar would be written from,
so no branch of `rule create` has ever written one)
6. Return the same output format as the API-backed branch (paths to created files)

#### Scenario: rule create --anonymous skips API
Expand Down
18 changes: 11 additions & 7 deletions packages/cli/src/agent/create-remote-rule.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Topic: create-remote-rule (CLI v%(CLI_VERSION)s / topic v2)
# Topic: create-remote-rule (CLI v%(CLI_VERSION)s / topic v3)

## You are here
This is `create-remote-rule`. It helps you have the Taskless service
Expand Down Expand Up @@ -126,12 +126,16 @@ Two ways to legitimately be here:
call succeeded or failed.

9. **Report.** The service writes the rule to
`.taskless/rules/sg/<id>/<id>.yml`, its tests to
`.taskless/rules/sg/<id>/.tests/<id>-YYYYMMDD-test.yml`, and
metadata to `.taskless/rule-metadata/<id>.yml`. These are the same
paths and the same shape a locally authored rule uses, so `check`,
`improve-rule`, `verify`, and `test` treat them identically. Show the
user the paths and suggest `%(TASKLESS_CLI)s agent check`.
`.taskless/rules/sg/<id>/<id>.yml` and its tests to
`.taskless/rules/sg/<id>/.tests/<id>-YYYYMMDD-test.yml`. These are
the same paths and the same shape a locally authored rule uses, so
`check`, `improve-rule`, `verify`, and `test` treat them
identically. Nothing is written under `.taskless/rule-metadata/`.
Show the user the paths and suggest `%(TASKLESS_CLI)s agent check`.

**Record the `ruleId` from the `--json` output.** It is the ticket
id the iterate endpoint is addressed by, `improve-rule` asks for it,
and no file on disk carries it.

## Input schema

Expand Down
7 changes: 4 additions & 3 deletions packages/cli/src/agent/create-sg-rule.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Topic: create-sg-rule (CLI v%(CLI_VERSION)s / topic v4)
# Topic: create-sg-rule (CLI v%(CLI_VERSION)s / topic v5)

## You are here
This is `create-sg-rule`. It helps you write an ast-grep rule: a check
Expand Down Expand Up @@ -268,8 +268,9 @@ metavariable, it always finds it.
## Important Notes

- Do NOT make any HTTP requests to taskless.io on this path.
- Do NOT write to `.taskless/rule-metadata/`: locally authored rules
have no metadata sidecar; they iterate via file edits.
- Do NOT write to `.taskless/rule-metadata/`: no rule has a metadata
sidecar, service-generated ones included, because the CLI never
writes that directory. Locally authored rules iterate via file edits.
- The verify loop is the quality gate. A clean failure is a legitimate
reason to escalate, but only with the user's confirmation (step 9).

Expand Down
10 changes: 5 additions & 5 deletions packages/cli/src/agent/delete-rule.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Topic: delete-rule (CLI v%(CLI_VERSION)s / topic v2)
# Topic: delete-rule (CLI v%(CLI_VERSION)s / topic v3)

## Goal
Remove an ast-grep rule and its associated test files from
Expand Down Expand Up @@ -28,11 +28,11 @@ rule is deleted by removing its directory by hand.
The CLI removes:
- `.taskless/rules/sg/<id>/`: the whole directory, which holds the
rule, its `.tests/`, and any per-rule config
- `.taskless/rule-metadata/<id>.yml` (if present)
- `.taskless/rule-metadata/<id>.yml`, a sidecar the CLI never
writes. The removal is there for a directory a person created by
hand; expect it to be absent.

3. **Report results.** Show the user what was deleted. If the rule
had metadata (was API-generated), note that the rule cannot be
re-iterated via the API path after deletion.
3. **Report results.** Show the user what was deleted.

## Errors

Expand Down
36 changes: 21 additions & 15 deletions packages/cli/src/agent/improve-rule.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Topic: improve-rule (CLI v%(CLI_VERSION)s / topic v4)
# Topic: improve-rule (CLI v%(CLI_VERSION)s / topic v5)

## Goal
Iterate on an existing Taskless rule. The CLI submits the user's
Expand All @@ -19,10 +19,11 @@ If the user wants the local-only flow (no API call), fetch
submitting. `auth login` does not fix it, no GitHub owner is a
property of the project, not the session.
- The target rule exists at `.taskless/rules/sg/<id>/<id>.yml`.
- The rule has metadata at `.taskless/rule-metadata/<id>.yml` (the
`ticketId` is required by the iterate endpoint). If metadata is
missing, the rule was created in anonymous mode and cannot be
iterated via the API path. Fetch the anonymous variant instead.
- You have the rule's **ticket id**: the value `%(TASKLESS_CLI)s rule
create --json` printed as `ruleId` when the rule was generated. The
iterate endpoint is addressed by that id. Nothing on disk holds it,
so it comes from the create output or from the user. Without it,
fetch the anonymous variant instead.

## Steps

Expand All @@ -33,13 +34,17 @@ If the user wants the local-only flow (no API call), fetch
Otherwise, list rules in `.taskless/rules/sg/` and ask which one.
Read the existing rule file so you can summarize what it does.

3. **Fetch the rule's metadata.** Run:
```
%(TASKLESS_CLI)s rule meta <id> --json
```
This returns the `ticketId` needed for the iterate request. If the
metadata is missing, the rule cannot be iterated via API. Fetch
`%(TASKLESS_CLI)s agent improve-rule --anonymous` instead.
3. **Get the ticket id.** This is the id the iterate endpoint is
addressed by, and it is the `ruleId` field from that rule's
`%(TASKLESS_CLI)s rule create --json` output. Take it from the
session that created the rule, or ask the user for it.

**Do not run `%(TASKLESS_CLI)s rule meta <id>` to get it.** That
command reads `.taskless/rule-metadata/<id>.yml`, a sidecar this CLI
never writes, so it exits 1 with `RULE_META_UNAVAILABLE` for every
rule. If no one has the ticket id, fetch
`%(TASKLESS_CLI)s agent improve-rule --anonymous` and iterate
locally.

4. **Gather improvement guidance.** Ask the user what should change:
- Are there false positives we need to exclude?
Expand Down Expand Up @@ -105,8 +110,9 @@ The `--from` JSON file conforms to:
%(INPUT_SCHEMA)s
```

`ruleId` is the original rule's ticket ID (returned by
`%(TASKLESS_CLI)s rule meta <id> --json`), not the YAML file name.
`ruleId` is the original rule's ticket ID, printed as `ruleId` by
`%(TASKLESS_CLI)s rule create --json`. It is not the YAML file name,
and it is not readable from anything under `.taskless/`.

## Errors

Expand All @@ -120,7 +126,7 @@ When `--json` is set, failures emit `{ ok: false, code, message }`:
| `NO_ORIGIN_REMOTE` | git repository, no `origin` | tell the user; `auth login` cannot fix it |
| `UNSUPPORTED_REMOTE_HOST`| `origin` is not GitHub | tell the user; `auth login` cannot fix it |
| `INVALID_INPUT` | `--from` JSON failed validation | re-read input schema, fix, retry |
| `RULE_NOT_FOUND` | metadata missing for the given rule | use anonymous variant or recreate via create |
| `RULE_NOT_FOUND` | the service has no such ticket id | re-check the id from `rule create --json` |
Comment thread
thecodedrift marked this conversation as resolved.
| `NETWORK_ERROR` | API submit/poll failed | report and suggest retry |
| `RULE_GENERATION_FAILED` | API returned a generation failure | report; suggest enriching guidance/references |
| `RULE_UNSUPPORTED` | plan lacks this generation type | tell the user to enable it; do not retry |
Expand Down
45 changes: 27 additions & 18 deletions packages/cli/src/agent/rule-meta.txt
Original file line number Diff line number Diff line change
@@ -1,30 +1,39 @@
# Topic: rule-meta (CLI v%(CLI_VERSION)s / topic v2)
# Topic: rule-meta (CLI v%(CLI_VERSION)s / topic v3)

## Goal
Read sidecar metadata for an API-generated rule. Used internally by
the `rule improve` recipe to fetch the `ticketId` needed for iteration.
Report what `%(TASKLESS_CLI)s rule meta` does today, so no recipe and no
agent builds a step on top of it.

## Preconditions
- The rule was created via the API path (anonymous-mode rules have
no metadata sidecar).
- `.taskless/rule-metadata/<id>.yml` exists.
## The sidecar does not exist

## Steps
`.taskless/rule-metadata/<id>.yml` is written from the `meta` block of a
rule status response. The rule service does not populate that block, so
this CLI has never written a sidecar for any rule, under any tier, in
any mode. `rule meta <id>` therefore has nothing to read and exits 1
with `RULE_META_UNAVAILABLE` for every id, including ids whose rule is
plainly on disk.

```
%(TASKLESS_CLI)s rule meta <id> --json
```
That code exists to keep the two cases apart. `RULE_NOT_FOUND` invites a
retry with a different id; there is no id that works.

Returns the metadata fields: `ticketId`, `generatedAt`, schema
version, etc.
## What to do instead

`rule improve` needs the ticket id, and that id comes from the machine
that created the rule, not from disk:

- `%(TASKLESS_CLI)s rule create --json` prints it as `ruleId` on
success. Record it when you create a rule you expect to iterate on.
- If the id was not recorded, ask the user for it.
- If nobody has it, iterate locally: fetch
`%(TASKLESS_CLI)s agent improve-rule --anonymous`.

## Errors

| code | meaning | fix |
|------------------|--------------------------|------------------------------------|
| `RULE_NOT_FOUND` | metadata sidecar missing | Use anonymous improve flow instead |
| `INVALID_INPUT` | metadata file malformed | File is corrupted; re-create rule |
| code | meaning | fix |
|-------------------------|------------------------------------------|---------------------------------------|
| `RULE_META_UNAVAILABLE` | no sidecar exists, and none is written | Use the ticket id from `rule create` |
| `INVALID_INPUT` | a sidecar exists and is malformed | Delete it; nothing here depends on it |

## See Also

- `%(TASKLESS_CLI)s agent improve-rule`: the primary consumer of this command
- `%(TASKLESS_CLI)s agent improve-rule`: how the ticket id is actually sourced
6 changes: 5 additions & 1 deletion packages/cli/src/agent/rule.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Topic: rule (CLI v%(CLI_VERSION)s / topic v1)
# Topic: rule (CLI v%(CLI_VERSION)s / topic v2)

## Goal
Umbrella for rule operations. Fetch the topic for the action you want.
Expand All @@ -13,6 +13,10 @@ Umbrella for rule operations. Fetch the topic for the action you want.
| Verify a rule | `%(TASKLESS_CLI)s agent verify-rule` (agent-internal) |
| Read rule metadata | `%(TASKLESS_CLI)s agent rule-meta` (agent-internal) |

`rule meta` reads a sidecar this CLI never writes, so it fails for every
rule. Fetch `rule-meta` only to learn what to do instead. `improve-rule`
takes the ticket id from `rule create --json`.

`route` is the entry point for authoring: it reads the request and
names the `create-*-rule` topic that fits, so you do not pick an engine
yourself.
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/api/rules.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { paths } from "../generated/api";
import { createApiClient } from "./client";
import { CLIError } from "../util/cli-error";
import { getCliPrefix } from "../util/package-manager";

// --- Types extracted from the generated schema ---
Expand Down Expand Up @@ -183,7 +184,15 @@ export async function iterateRule(
throw new Error("Access denied to this request.");
}
if (response.status === 404 && errorData.error === "request_not_found") {
throw new Error("Rule not found. It may have expired.");
// The caller supplied this ticket id (from `rule create --json`, or from
// the user), so "no such id" is a state they can act on: re-check the id.
// The code travels on the error so `improveCommand` reports
// RULE_NOT_FOUND rather than folding it into NETWORK_ERROR, which would
// tell an agent to retry an id that will never resolve.
throw new CLIError(
"Rule not found. It may have expired.",
"RULE_NOT_FOUND"
);
}
if (
response.status === 404 &&
Expand Down
41 changes: 36 additions & 5 deletions packages/cli/src/commands/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,13 @@ const createCommand = defineCommand({
successCases: request.successCases,
failureCases: request.failureCases,
});
ruleId = response.ruleId;
// The canonical field. `ruleId` is still on the response, carrying the
// same value, and is marked deprecated: it never named a rule. The
// local keeps its name because it feeds this command's own `--json`
// `ruleId` field, which is OUR published contract and a separate
// decision from the service's — renaming that one moves the ground
// under every recipe and agent that reads it.
ruleId = response.requestId;
} catch (error) {
fail(
error instanceof Error ? error.message : String(error),
Expand Down Expand Up @@ -256,6 +262,11 @@ const createCommand = defineCommand({
}
}

// Dead in practice, and kept deliberately. The service does not
// populate `meta` on a status response, so no sidecar has ever
// been written from here; the branch is what would start writing
// one the day it does. `rule meta` says as much when asked, rather
// than the code pretending the file is merely absent.
if (status.meta) {
const metaFiles = await writeRuleMetaFiles(cwd, status.meta);
writtenFiles.push(...metaFiles);
Expand Down Expand Up @@ -429,9 +440,13 @@ const improveCommand = defineCommand({
});
requestId = response.requestId;
} catch (error) {
// `iterateRule` marks the failures the caller can act on by throwing a
// CLIError carrying the code (a 404 on the supplied ticket id is
// RULE_NOT_FOUND, not something to retry). Everything else really is
// an unclassified transport/API failure.
fail(
error instanceof Error ? error.message : String(error),
"NETWORK_ERROR"
error instanceof CLIError && error.code ? error.code : "NETWORK_ERROR"
);
}

Expand Down Expand Up @@ -513,6 +528,11 @@ const improveCommand = defineCommand({
}
}

// Dead in practice, and kept deliberately. The service does not
// populate `meta` on a status response, so no sidecar has ever
// been written from here; the branch is what would start writing
// one the day it does. `rule meta` says as much when asked, rather
// than the code pretending the file is merely absent.
if (status.meta) {
const metaFiles = await writeRuleMetaFiles(cwd, status.meta);
writtenFiles.push(...metaFiles);
Expand Down Expand Up @@ -570,7 +590,8 @@ const improveCommand = defineCommand({
const metaCommand = defineCommand({
meta: {
name: "meta",
description: "Show sidecar metadata for a rule",
description:
"Show sidecar metadata for a rule (no sidecar is written by this version)",
},
args: {
dir: {
Expand Down Expand Up @@ -610,11 +631,21 @@ const metaCommand = defineCommand({
throw new CLIError(message, code, { reported: true });
}

// The sidecar is still READ, so a file that exists is reported. What
// changed is what its absence means. It is not "this rule has no
// metadata": no rule does. The sidecar is written from the `meta` block of
// a rule status response, the service does not populate that block, and so
// this CLI has never written one. Reporting RULE_NOT_FOUND sent agents
// looking for a rule that was on disk the whole time.
const meta = await readRuleMetaFile(cwd, args.id);
if (!meta) {
fail(
`No metadata found for rule "${args.id}". Expected .taskless/rule-metadata/${args.id}.yml`,
"RULE_NOT_FOUND"
`No metadata sidecar exists for rule "${args.id}", and this version of the CLI never writes one: ` +
`the rule service does not return the metadata block that ` +
`.taskless/rule-metadata/ is written from. This is not specific to "${args.id}". ` +
`To iterate on a rule, pass the ticket id returned as "ruleId" by \`taskless rule create --json\` ` +
`to \`taskless rule improve --from <file>\`, or use the local-only improve flow.`,
"RULE_META_UNAVAILABLE"
);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/prompts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const TOPICS = [
* destination before the service is involved, `create-remote-rule` states
* the boundary from the client's side, `detect` documents a CLI subprocess a
* Worker cannot spawn, `create-legacy-rule` targets a local toolchain, and
* `rule-meta` reads an `improve-rule` sidecar file.
* `rule-meta` describes a local sidecar file the CLI never writes.
*/
export const INTERNAL_TOPICS = [
"auth",
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/types/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ export type CLIErrorCode =
| "RULE_GENERATION_FAILED"
| "RULE_UNSUPPORTED"
| "RULE_NOT_FOUND"
// `rule meta` reads a sidecar under `.taskless/rule-metadata/`, written from
// the `meta` block of a rule status response. The service does not populate
// that block, so this CLI never writes a sidecar and the command has no data
// to return. Distinct from RULE_NOT_FOUND, which says "look for a different
// id": there is no id that produces a sidecar, and an agent that reads this
// as "not found" retries or reports a missing rule that is plainly on disk.
| "RULE_META_UNAVAILABLE"
| "INVALID_INPUT"
| "NETWORK_ERROR"
| "SCAN_FAILED"
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/test/anonymous-flag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ describe("--anonymous flag (per-command behavior matrix)", () => {

describe("rule meta --anonymous", () => {
it("accepts the flag as no-op", async () => {
// Rule doesn't exist → RULE_NOT_FOUND regardless of --anonymous
// No sidecar is ever written → RULE_META_UNAVAILABLE, and --anonymous
// changes nothing about that.
await mkdir(join(cwd, ".taskless"), { recursive: true });
await writeFile(
join(cwd, ".taskless", "taskless.json"),
Expand All @@ -164,7 +165,7 @@ describe("--anonymous flag (per-command behavior matrix)", () => {
]);
expect(result.exitCode).not.toBe(0);
const parsed = JSON.parse(result.stdout) as { code: string };
expect(parsed.code).toBe("RULE_NOT_FOUND");
expect(parsed.code).toBe("RULE_META_UNAVAILABLE");
});
});

Expand Down
Loading
Loading