Skip to content
Open
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
29 changes: 17 additions & 12 deletions openspec/changes/cli-v2-rule-api/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,28 +49,29 @@ upgradeUrl }`, strip C0/C1 control characters except newline from

## 3. Generation on v2 (slice 2)

- [ ] 3.1 Add `rules/verify-delivery.ts`: verify a served file set against its
- [x] 3.1 Add `rules/verify-delivery.ts`: verify a served file set against its
`signatures` (every signature names a file, every non-`.tests/` file has
one, each hash matches, runtime `signature` equals the `check.ts` entry)
and that `rules` holds exactly one set whose `id` is the requested id.
Unit tests for each refusal.
- [ ] 3.2 Make `writeDeliveredFileSet` the only write path for a served rule and
make it replace the directory (purge files the set lacks, `.tests/`
included; create each file's parent directories).
Drop the legacy single-`content` branch from `deliver.ts` and
`files.ts`. `deliver.test.ts` covers a local extra capture being removed.
- [ ] 3.3 Move `rule create` to v2: submit, poll, fetch each produced
- [x] 3.2 Make `writeDeliveredFileSet` the only write path for a served rule
(`writeServedRule`) and make it replace the directory (purge files the
set lacks, `.tests/` included; create each file's parent directories).
`deliver.test.ts` covers a stale fixture and a local extra capture being
removed. The legacy single-`content` branch still has a caller in the v1
repair path until 5.3, so it is dropped in 8.1.
- [x] 3.3 Move `rule create` to v2: submit, poll, fetch each produced
`{ ruleId, revisionId }` head in parallel without `revision`, confirm
`revisionId`, verify, write. Print `error` verbatim (sanitized) on
`failed` / `unsupported`. `--json` prints `requestId` and `rules`, no
`ruleId`; update `schemas/rules-create.ts`. Tests use a stubbed v2 server.
- [ ] 3.4 Move `rule improve` to `POST v2/rule/{ruleId}/iterate`, with the input
- [x] 3.4 Move `rule improve` to `POST v2/rule/{ruleId}/iterate`, with the input
`ruleId` meaning the directory name; `404 rule_not_found` →
`RULE_NOT_FOUND`. Tests cover success and the not-found code.
- [ ] 3.5 Keep the write-time entitlement warning for runtime sets served with
- [x] 3.5 Keep the write-time entitlement warning for runtime sets served with
`runtimeSignatures: false`; `rule-create-entitlement.test.ts` passes
against v2 fixtures.
- [ ] 3.6 Update the `create-remote-rule`, `improve-rule`, and `rule-meta`
- [x] 3.6 Update the `create-remote-rule`, `improve-rule`, and `rule-meta`
recipes: record the rule ids from `rules`, pass a directory name to
`improve`, never the request id. `recipe-cross-references.test.ts` passes.

Expand Down Expand Up @@ -156,8 +157,12 @@ upgradeUrl }`, strip C0/C1 control characters except newline from

## 8. Retire v1 (slice 5)

- [ ] 8.1 Delete `api/rules.ts`, `api/reconcile.ts`, `api/restore.ts`, the
frozen v1 `api.schema.json` / `api.d.ts`, and every v1 type use; move `auth/whoami.ts` and `auth/org.ts` to v2 whoami. Verify
- [ ] 8.1 Delete `api/rules.ts` (its v1 request, poll, and iterate calls went
in slice 2), `api/reconcile.ts`, `api/restore.ts`, the frozen v1
`api.schema.json` / `api.d.ts`, the legacy single-`content` path in
`files.ts` / `deliver.ts` (`writeRuleFile`, `writeRuleTestFile`,
`writeRuleMetaFiles`, `deliveredFiles`, `resolveIngestEngine`), and
every v1 type use; move `auth/whoami.ts` and `auth/org.ts` to v2 whoami. Verify
`grep -rn "/cli/api/" packages/cli/src` finds only `/cli/api/v2/` paths.
- [ ] 8.2 Add a vite build check (per the code style guide, not a test that
scans output) that fails the build if the bundle contains a `/cli/api/`
Expand Down
27 changes: 15 additions & 12 deletions packages/cli/src/agent/create-remote-rule.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Topic: create-remote-rule (CLI v%(CLI_VERSION)s / topic v3)
# Topic: create-remote-rule (CLI v%(CLI_VERSION)s / topic v4)

## You are here
This is `create-remote-rule`. It helps you have the Taskless service
Expand Down Expand Up @@ -125,17 +125,20 @@ Two ways to legitimately be here:
8. **Clean up.** Delete `.taskless/.tmp-rule-request.json` whether the
call succeeded or failed.

9. **Report.** The service writes the rule to
`.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.
9. **Report.** The CLI writes each generated rule, fixtures included,
to `.taskless/rules/<engine>/<id>/`, replacing anything already in
that directory. 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`.

**`rules` in the `--json` output lists each written rule's id**, and
the id is the rule's directory name (for example
`no-eval-3fa9c21b`). It is what `rule improve`, `rule restore`, and
`rule rollback` take, and it is on disk, so nothing needs recording.
`requestId` names the generation request only; no command takes it
back, and passing it to `rule improve` fails with `RULE_NOT_FOUND`.

**Read `notices` if it is present.** It is an optional array of
advisory messages about a delivery that was written anyway. A rule
Expand Down
45 changes: 20 additions & 25 deletions packages/cli/src/agent/improve-rule.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Topic: improve-rule (CLI v%(CLI_VERSION)s / topic v5)
# Topic: improve-rule (CLI v%(CLI_VERSION)s / topic v6)

## Goal
Iterate on an existing Taskless rule. The CLI submits the user's
guidance to the Taskless API iterate endpoint, which returns an
updated rule that overwrites the original on disk. The agent's job
is to gather the right ruleId + guidance + supporting references and
is to gather the right rule id + guidance + supporting references and
to report the result.

If the user wants the local-only flow (no API call), fetch
Expand All @@ -18,33 +18,28 @@ If the user wants the local-only flow (no API call), fetch
`[unknown]`, stop and say the tier is unavailable rather than
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`.
- 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.
- The target rule exists at `.taskless/rules/<engine>/<id>/`, and the
Taskless service issued it. Its **rule id is its directory name**
(for example `no-eval-3fa9c21b`), which is what the iterate endpoint
is addressed by. A rule you wrote locally, or one generated before
CLI 0.12.0, is not known to the service: improving it fails with
`RULE_NOT_FOUND`, so fetch the anonymous variant instead.

## Steps

1. **Confirm auth.** Run `%(TASKLESS_CLI)s info --json` and check
`loggedIn`. If false, fetch `%(TASKLESS_CLI)s agent auth`.

2. **Identify the rule to improve.** If the user named one, use it.
Otherwise, list rules in `.taskless/rules/sg/` and ask which one.
Read the existing rule file so you can summarize what it does.
Otherwise, list the rule directories under `.taskless/rules/<engine>/`
and ask which one. Read the existing rule files so you can summarize
what the rule does.

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.
3. **Take the rule id from the directory name.** It is the `<id>` in
`.taskless/rules/<engine>/<id>/`, and the same value
`%(TASKLESS_CLI)s rule create --json` listed in `rules`. It is never
the `requestId` that command printed. You do not need
`%(TASKLESS_CLI)s rule meta` for it; that command has nothing to read.

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

`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/`.
`ruleId` is the rule's directory name under `.taskless/rules/<engine>/`,
as `%(TASKLESS_CLI)s rule create --json` lists it in `rules`. It is not
the `requestId` from that output.

## Errors

Expand All @@ -132,7 +127,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` | the service has no such ticket id | re-check the id from `rule create --json` |
| `RULE_NOT_FOUND` | the service did not issue this rule | re-check the directory name; a local or pre-0.12.0 rule needs the anonymous flow |
| `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
19 changes: 8 additions & 11 deletions packages/cli/src/agent/rule-meta.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Topic: rule-meta (CLI v%(CLI_VERSION)s / topic v3)
# Topic: rule-meta (CLI v%(CLI_VERSION)s / topic v4)

## Goal
Report what `%(TASKLESS_CLI)s rule meta` does today, so no recipe and no
Expand All @@ -18,22 +18,19 @@ retry with a different id; there is no id that works.

## 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`.
Nothing you would have used it for needs it. `rule improve`,
`rule restore`, and `rule rollback` take a rule's id, and the id is the
rule's directory name under `.taskless/rules/<engine>/`, which is on
disk. `%(TASKLESS_CLI)s rule create --json` lists the same ids in
`rules`.

## Errors

| code | meaning | fix |
|-------------------------|------------------------------------------|---------------------------------------|
| `RULE_META_UNAVAILABLE` | no sidecar exists, and none is written | Use the ticket id from `rule create` |
| `RULE_META_UNAVAILABLE` | no sidecar exists, and none is written | Use the rule's directory name |
| `INVALID_INPUT` | a sidecar exists and is malformed | Delete it; nothing here depends on it |

## See Also

- `%(TASKLESS_CLI)s agent improve-rule`: how the ticket id is actually sourced
- `%(TASKLESS_CLI)s agent improve-rule`: iterating a rule by its id
5 changes: 3 additions & 2 deletions packages/cli/src/agent/rule.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Topic: rule (CLI v%(CLI_VERSION)s / topic v2)
# Topic: rule (CLI v%(CLI_VERSION)s / topic v3)

## Goal
Umbrella for rule operations. Fetch the topic for the action you want.
Expand All @@ -15,7 +15,8 @@ Umbrella for rule operations. Fetch the topic for the action you want.

`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`.
takes the rule's id, which is its directory name under
`.taskless/rules/<engine>/`.

`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
Expand Down
160 changes: 0 additions & 160 deletions packages/cli/src/api/rules.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
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 @@ -48,160 +45,3 @@ export function isSingleContentRule(
): rule is SingleContentRule {
return !isFileSetRule(rule);
}

// --- Helpers ---

/** Extract error details from an untyped error response body */
function parseErrorBody(rawError: unknown): Record<string, unknown> {
if (rawError && typeof rawError === "object") {
return rawError as Record<string, unknown>;
}
return {};
}

/**
* The server returns the same 404 `organization_not_found` whether the org
* isn't yours or its GitHub App installation doesn't cover this repository (it
* deliberately doesn't distinguish, to avoid leaking org existence), so the
* message names both causes — coverage first, since a resolved org subject
* makes membership the less likely one.
*/
function orgNotFoundMessage(): string {
return [
"Taskless could not act on this repository for your organization.",
"",
"Most often the organization's Taskless GitHub App installation does not cover this repository. It can also mean your login no longer has access to the organization.",
"",
"- Confirm the Taskless app is installed on this repository's owner and includes this repository.",
`- If access recently changed, re-authenticate with \`${getCliPrefix()} auth login\`.`,
].join("\n");
}

// --- API functions ---

/** Submit a new rule generation request */
export async function submitRule(
token: string,
request: {
/** Org subject: Taskless UUID (preferred) or numeric GitHub org id. */
orgId: string | number;
repositoryUrl: string;
prompt: string;
successCases?: string[];
failureCases?: string[];
}
) {
const client = createApiClient(token);
const { data, error, response } = await client.POST("/cli/api/request", {
body: request,
});

if (!data) {
const errorData = parseErrorBody(error);
if (response.status === 400 && errorData.error === "validation_error") {
const details = (errorData.details as string[]) ?? [];
throw new Error(`Validation error: ${details.join(", ")}`);
}
if (
response.status === 403 &&
errorData.error === "repository_not_accessible"
) {
throw new Error(
[
"Repository is not accessible to this organization.",
"",
"- Verify that your local `origin` remote points to the intended GitHub repository.",
"- Confirm that your GitHub user/organization has access to that repository.",
`- If you recently changed access or remotes, try re-authenticating with \`${getCliPrefix()} auth login\`.`,
].join("\n")
);
}
if (
response.status === 404 &&
errorData.error === "organization_not_found"
) {
throw new Error(orgNotFoundMessage());
}
throw new Error(
`Request submission failed (HTTP ${String(response.status)})`
);
}

return data;
}

/** Poll for rule generation status */
export async function pollRuleStatus(token: string, requestId: string) {
const client = createApiClient(token);
const { data, error, response } = await client.GET(
"/cli/api/request/{requestId}",
{
params: { path: { requestId } },
}
);

if (!data) {
const errorData = parseErrorBody(error);
if (response.status === 403 && errorData.error === "access_denied") {
throw new Error("Access denied to this request.");
}
if (response.status === 404 && errorData.error === "request_not_found") {
throw new Error("Request not found. It may have expired.");
}
throw new Error(`Status polling failed (HTTP ${String(response.status)})`);
}

return data;
}

/** Submit an improve/iterate request for an existing rule */
export async function iterateRule(
token: string,
requestId: string,
request: {
/** Org subject: Taskless UUID (preferred) or numeric GitHub org id. */
orgId: string | number;
guidance: string;
references?: Array<{ filename: string; content: string }>;
}
) {
const client = createApiClient(token);
const { data, error, response } = await client.POST(
"/cli/api/request/{requestId}/iterate",
{
params: { path: { requestId } },
body: request,
}
);

if (!data) {
const errorData = parseErrorBody(error);
if (response.status === 400 && errorData.error === "validation_error") {
const details = (errorData.details as string[]) ?? [];
throw new Error(`Validation error: ${details.join(", ")}`);
}
if (response.status === 403 && errorData.error === "access_denied") {
throw new Error("Access denied to this request.");
}
if (response.status === 404 && errorData.error === "request_not_found") {
// 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 &&
errorData.error === "organization_not_found"
) {
throw new Error(orgNotFoundMessage());
}
throw new Error(`Iterate request failed (HTTP ${String(response.status)})`);
}

return data;
}
Loading
Loading