From 7c0b1d0d882f3f06c2315d30f4dead39ada4afaa Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 16:02:42 -0700 Subject: [PATCH 01/28] docs(openspec): Propose local-rule-routing change Add the change contract for a local-first rule-routing layer: a deterministic detect command plus route/existing/static/remote recipes that keep authoring on-device and gate the login wall behind reasonable confidence with a confirm-before-service fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../changes/local-rule-routing/.openspec.yaml | 2 + openspec/changes/local-rule-routing/design.md | 265 ++++++++++++++++++ .../changes/local-rule-routing/proposal.md | 87 ++++++ .../specs/cli-detect/spec.md | 69 +++++ .../local-rule-routing/specs/cli-help/spec.md | 32 +++ .../specs/cli-rule-routing/spec.md | 199 +++++++++++++ .../specs/skill-taskless/spec.md | 57 ++++ 7 files changed, 711 insertions(+) create mode 100644 openspec/changes/local-rule-routing/.openspec.yaml create mode 100644 openspec/changes/local-rule-routing/design.md create mode 100644 openspec/changes/local-rule-routing/proposal.md create mode 100644 openspec/changes/local-rule-routing/specs/cli-detect/spec.md create mode 100644 openspec/changes/local-rule-routing/specs/cli-help/spec.md create mode 100644 openspec/changes/local-rule-routing/specs/cli-rule-routing/spec.md create mode 100644 openspec/changes/local-rule-routing/specs/skill-taskless/spec.md diff --git a/openspec/changes/local-rule-routing/.openspec.yaml b/openspec/changes/local-rule-routing/.openspec.yaml new file mode 100644 index 00000000..e0c0898f --- /dev/null +++ b/openspec/changes/local-rule-routing/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-11 diff --git a/openspec/changes/local-rule-routing/design.md b/openspec/changes/local-rule-routing/design.md new file mode 100644 index 00000000..1a0d8493 --- /dev/null +++ b/openspec/changes/local-rule-routing/design.md @@ -0,0 +1,265 @@ +## Context + +The Taskless skill is a thin router: it holds no recipes and delegates to +`npx @taskless/cli help `, which returns a bundled `.txt` recipe embedded +at build time. Today the only authoring entry is `rule create`, which is +rule-type-agnostic, login-gated, and runs generation off the developer's machine +via the Taskless service. The service-side classifier that decides static vs +runtime (`classifyStep`, Runtime Rules project) is **not reachable locally** — +it is login-only by design, because it carries inference off-machine. + +Three constraints shape this design: + +1. **No local classifyStep.** The local path cannot call the service classifier. + It must make a coarser, cheaper decision on-device. +2. **Login is a wall.** Reaching the service requires auth. Sending a user there + unnecessarily is the failure mode we are designing against. +3. **The skill adds no rule knowledge.** Per the existing router philosophy, all + judgment lives in fetched recipes; the CLI ships deterministic signals and + recipe text, never a maintained catalog of linter rules. + +The check pipeline already models multi-scanner aggregation (`source` field), and +remote rule generation already writes the same on-disk shape and paths as local +generation (canonical shape pinned by the Runtime Rules project). So downstream +tools (`check`, `improve`, `verify`) see a single dialect regardless of origin. + +## Goals / Non-Goals + +**Goals:** + +- A deterministic, offline `taskless detect --json` that reports repo signals + (linters configured, languages/frameworks, the repo's own rule styles). +- A local routing recipe (`route`) that reasons first, then commits to the + believed-correct destination (`existing | static | remote`) on reasonable + confidence, biased to stay local. +- Try-verify-escalate as the _failure fallback_: a path committed to on reasonable + belief that fails verification escalates — and escalation to the service prompts + and confirms with the user before spending a generation. +- Asking the user when multiple paths fit, with trade-offs (including the + generation cost of `remote`). +- Authoring recipes for each destination (`existing`, `static`, `remote`). +- Reverse the skill's named-tool suppression so naming a linter engages routing. + +**Non-Goals:** + +- Defining or computing static-vs-runtime classification locally. That cut is + owned by the Runtime Rules project and lives behind `remote`. +- Maintaining any catalog/knowledge-graph of linter rules or a `howto` API + (explicitly rejected — see Decisions). Linter knowledge is sourced at author + time from the repo and the agent's WebSearch/WebFetch. +- Changing the `rule create` remote backend, the canonical on-disk rule shape, or + `check`/`improve`/`verify` behavior. +- Running external linters inside `taskless check` (the `existing` path is + author-only; the user's own toolchain runs the rule). + +## Decisions + +### D1 — `detect` is a real command; routing is recipes + +`taskless detect --json` is an executable, deterministic command. `route`, +`existing`, `static`, `remote` are `help` recipes the agent follows. + +_Why:_ A CLI cannot classify natural-language intent without an LLM, so the +"what kind of rule is this" judgment must live in a recipe the agent executes. +But "what linters/languages are present" is pure signal and benefits from +determinism — making it a command gives the recipe a stable, testable input and +keeps the agent from hallucinating the repo's tooling. + +_Alternative considered:_ a single `taskless route ""` command that +classifies directly. Rejected — it would need an on-device LLM call or a brittle +heuristic, and it couples a deterministic signal scan to a judgment that belongs +in the agent. + +### D2 — `route` commits to the believed-correct path; escalation is the failure fallback + +`route` determines the destination **upfront** from `detect` signals plus intent, +choosing the path it _believes_ is correct. The bar to commit to a local path is +**reasonable confidence**, not certainty — because the safety net is an honest +fallback, not a perfect prediction. The flow: + +``` +route (reason FIRST, then decide, then act): + 0. write the rationale BEFORE naming a destination: + - what the `detect` signals show (linters, languages, repo rule styles) + - whether an existing linter plausibly already covers this + - whether the pattern is expressible as a simple static ast-grep rule + - the resulting confidence that it is locally solvable + 1. existing AND static both fit? -> ASK the user, explain trade-offs (D8) + 2. existing linter clearly fits? -> existing (no login) + 3. reasonably confident simple static? -> static (author + verify) (no login) + 4. not reasonably confident it's local? -> remote (login) + + FALLBACK (failure state): a path we believed in (static) fails verification + -> PROMPT the user and CONFIRM before calling the service + ("local rule couldn't capture the cases; generate via Taskless? + this uses a generation and needs login") -> on yes -> remote +``` + +The destination is emitted **after** the rationale and must follow from it. The +recipe forbids naming a route before the reasoning is written. + +_Why (reason-before-route):_ Confidence assessments are unreliable when the model +names a destination first and justifies it after — it rationalizes a snap call. +Requiring the rationale to be written _before_ the route is named conditions the +decision on the contextual work (what `detect` shows, whether a packaged rule +exists, whether ast-grep can express the pattern), so the confidence judgment is +earned rather than asserted. The route must be a conclusion of the reasoning. + +_Why (commit on reasonable confidence, not certainty):_ Demanding high confidence +to act locally would push borderline-but-solvable requests to the service +unnecessarily — and the service costs generations (D8). Reasonable confidence is +enough to _commit_ to local because try-verify-escalate backstops a wrong bet: +the `static` recipe authors the rule and verifies it against the user's cases, and +if that believed-correct attempt fails, the recipe escalates. This is the +legitimate role of try-verify-escalate — a **failure handler for a path we +believed in**, not a route-selection probe. + +_Why escalation prompts and confirms:_ The service consumes a generation and +requires login. A local attempt that fails must NOT silently fall through to the +service — the recipe surfaces the failure and asks the user to confirm the +service call. This keeps generations from being spent without consent and makes +the (now justified) login ask explicit. + +_The distinction that matters:_ what is rejected is **fail-first as the selection +mechanism** — deliberately attempting local with no real belief it will work, just +to manufacture a justification for `remote`. What is kept is **try-verify-escalate +as a fallback** — committing to local on reasonable belief, and escalating (with +confirmation) only when that genuine attempt fails. `route` never names "runtime"; +it only judges "am I reasonably confident this is a simple local rule?" and, when +not, routes remote upfront. + +_Alternative considered (rejected):_ escalate to `remote` on _uncertainty alone_. +Rejected — uncertainty must not by itself send users to a generation-consuming, +login-gated path; only a reasoned judgment (confident-local, or a verified local +failure) drives the choice. + +### D3 — `detect` emits pure repo signals only; no rule-pattern detection + +`detect` reports linters present, languages/frameworks, and the repo's own +rule-authoring styles. It does **not** try to match a request against known +packaged rules (e.g. "this is `no-console`"). + +_Why:_ The space of packaged-rule detections is effectively infinite and changes +constantly across ecosystems — a poor determinism target that would drag a +maintained catalog back into the CLI. That judgment is cheap for the LLM in the +`existing` recipe (repo + WebFetch) and expensive to keep correct in code. + +### D4 — Knowledge is sourced at author time, not maintained by Taskless + +The `existing` recipe instructs the agent to mine the repo's own rules of the +relevant kind first, then WebFetch the linter's current docs only if that is +thin. Taskless ships the recipe (where to look), not the knowledge. + +_Why:_ A Taskless-hosted knowledge graph / `howto` endpoint is WebSearch/WebFetch +in disguise with a perpetual scraping-and-staleness maintenance bill. The agent +already has fresh web access; the repo is the highest-signal source for house +style. (There is precedent for `/cli/api/*` endpoints if server-fresh recipes are +ever needed, but bundled-first / API-as-enrichment remains the posture.) + +### D5 — `route` replaces `rule create` as the authoring front door + +The skill and topic index point "author a rule" requests at `route`, not directly +at `rule create`. `rule create` remains the executable backend that the `remote` +recipe invokes; it is unchanged. + +_Why:_ It inserts the local-first decision before any login-gated path, without +disturbing the working remote backend. + +### D6 — Local `static` output matches the canonical shape + +The `static` recipe writes the same on-disk rule shape and paths as remote +generation (the canonical shape pinned upstream). + +_Why:_ `check`, `improve`, and `verify` must see one dialect. Divergent local vs +remote output would fork all three downstream tools. + +### D7 — The skill `description` 1024-char ceiling is a gating check, not an afterthought + +The Agent Skills spec caps the skill `description` at 1024 characters. Reversing +the named-tool suppression and adding routing trigger language both consume that +budget. Every edit to the description SHALL be measured against 1024, and trigger +wording SHALL be tightened (reworded shorter) rather than appended when the budget +is tight. + +_Why:_ The description is the skill's trigger surface; silently overflowing 1024 +breaks loading or truncates triggers. Capturing the limit as a first-class +constraint now — before more capabilities pile trigger phrases onto the field — +prevents a later capability from being blocked by a full description. This is why +the constraint is surfaced in the proposal and carries an explicit length +scenario in the spec, not just a code comment. + +### D8 — When multiple paths fit, ask the user and explain trade-offs + +When more than one destination genuinely fits — most often `existing` and `static` +both viable — `route` SHALL present the options to the user with their trade-offs +rather than silently picking one. The trade-off framing SHALL include that the +service path (`remote`) consumes a generation and requires login, so it is the +right tool when something _cannot_ be solved locally, not a default. + +_Why:_ When two local paths both work, the choice is a user preference (which +toolchain owns the rule), not a technical fact the recipe should decide for them. +Surfacing it builds trust and keeps the user in control of where their rules live. +Naming the generation cost of `remote` makes the local-first bias legible: the +service is valuable precisely when local can't deliver, and spending a generation +should be a conscious choice. + +_Resolves open question:_ "should `route` offer `existing` and `static` in +parallel?" — yes, when both fit or the agent is unsure, ask and explain. + +## Risks / Trade-offs + +- **[`route` is over-confident and commits local when it shouldn't]** → The + `static` recipe verifies the authored rule against the user's success/failure + cases before reporting success, so a wrong-but-reasonable local bet surfaces as a + verified failure, not a silently bad rule. That failure escalates via try-verify- + escalate — the legitimate fallback — and the escalation prompts-and-confirms + before spending a generation, so an over-confident bet costs at most a confirm + dialog, never a silent service charge. +- **[`route` demands too much confidence and pushes solvable requests to the + service]** → The commit bar is _reasonable_ confidence, not certainty (D2), + precisely because the fallback backstops a wrong bet cheaply. Tuned with the + honesty eval fixtures across both failure directions. +- **[Reversing suppression pushes the skill `description` over the 1024-char Agent + Skills ceiling]** → The 1024 limit is a gating check on every description edit + (D7); trigger wording is tightened, not appended, and the spec carries an + explicit length scenario. +- **[`existing` author-only confuses users who expect `taskless check` to run + their linter]** → Recipe text must be explicit that the user's own toolchain + runs the authored rule; Taskless does not aggregate external linters in v1. +- **[Recipe drift vs the Runtime Rules canonical shape]** → D6 ties local output + to the upstream shape; verification (`rule verify`) catches shape regressions. +- **[Skill trigger reversal causes false engagement on tool-named requests]** → + The trigger still anchors on rule-authoring intent; naming a linter routes to + `route`, which can still conclude "this is a one-line config in your existing + tool" without heavy machinery. + +## Migration Plan + +1. Ship `detect` (additive command, no behavior change to existing commands). +2. Add the four recipes and register them in the help index (additive). +3. Update `skill-taskless` trigger/router text to engage `route`. This is the + only behavior change users perceive; it is reversible by restoring the + suppression clause. +4. No data migration; no change to on-disk rule shape or stored state. + +Rollback: revert the skill text and unregister the recipes; `detect` can remain +harmlessly as an unused command. + +## Resolved Questions + +- **Offer `existing` and `static` in parallel when both fit?** Resolved (D8): yes + — when both fit or the agent is unsure, ask the user and explain trade-offs, + including that `remote` consumes a generation and is for what can't be solved + locally. +- **How confident is "confident enough" to commit to `static`?** Resolved (D2): + _reasonably_ confident, not certain — because try-verify-escalate (with a + prompt-and-confirm before the service) backstops a wrong-but-reasonable bet, so + borderline-solvable requests are not pushed to the service unnecessarily. The + honesty eval fixtures calibrate the threshold in recipe language. +- **Where does the `static` candidate live and how is it cleaned up on fallback?** + Resolved: mirror the existing `.taskless/.tmp-*` + guaranteed-cleanup pattern + from `rule create` (cleanup on both success and failure). + +## Open Questions + +- None outstanding. diff --git a/openspec/changes/local-rule-routing/proposal.md b/openspec/changes/local-rule-routing/proposal.md new file mode 100644 index 00000000..f1778581 --- /dev/null +++ b/openspec/changes/local-rule-routing/proposal.md @@ -0,0 +1,87 @@ +## Why + +Authoring a rule today routes straight to `rule create`, which is login-gated and +runs generation off the developer's machine. There is no local front door that +asks "what kind of rule is this, and can I build it on-device first?" — so users +hit a login wall before Taskless has demonstrated it needs the service. At the +same time, the skill actively _suppresses_ itself when a user names a linter +(eslint, ruff, biome), stepping back from exactly the requests where Taskless +could help author the rule in that tool's own dialect. We want a local routing +layer that keeps authoring on-device whenever possible, engages other linters +instead of deferring, and only suggests login when a rule provably cannot be +built locally. + +## What Changes + +- **NEW `taskless detect --json`** — a deterministic, offline repo-signal scan: + which linters are configured, languages/frameworks present, and the styles of + the repo's own existing rules. No LLM, no network. Feeds the routing recipe. +- **NEW routing recipe layer** under `help`, replacing the rule-type-agnostic + `rule create` entry as the front door for "author a rule": + - `route` — the lightweight **local classifier**. Biased to stay local; + suggests login only when local authoring provably fails. + - `existing` — author a rule in a linter already detected in the repo, in that + tool's dialect, sourced from the repo's own rules plus the agent's WebFetch. + - `static` — author a local ast-grep rule on-device, verified against the + user's success/failure cases (the lineage of `rule create --anonymous`). + - `remote` — collect inputs and call the Taskless service, which runs the heavy + classifier and returns either a static or a runtime rule (login required). +- **NEW confidence-gated routing contract** — `route` first writes an explicit + rationale (what `detect` shows, whether an existing linter covers it, whether + ast-grep can express it, resulting local-solvability confidence), and only then + names the destination it believes is correct, as a conclusion of that reasoning. + It commits to a local path on **reasonable** confidence (not certainty); when + that confidence is absent it selects `remote` directly. Try-verify-escalate is + the _failure fallback_: a local path committed to in good faith that fails + verification escalates — but escalation to the generation-consuming, login-gated + service **prompts and confirms with the user first**, never silently. When + multiple paths fit, `route` asks the user and explains the trade-offs. +- **MODIFIED skill trigger posture** — naming a specific linter should _engage_ + the authoring flow via `route`, not quiet the skill to a one-line offer. +- This change does **not** define static-vs-runtime classification. That cut is + owned upstream by the Runtime Rules project and lives behind `remote`; `route` + only decides local-vs-remote. + +## Capabilities + +### New Capabilities + +- `cli-detect`: A deterministic `taskless detect --json` command that scans the + working directory for linter configs, languages/frameworks, and the repo's own + rule-authoring styles, emitting structured signals for downstream routing. No + inference, no network. +- `cli-rule-routing`: The `route` / `existing` / `static` / `remote` recipe layer + and the confidence-gated routing contract that determines the destination + upfront, keeps authoring local when there is confidence it is locally solvable, + and routes to the login-gated service directly when that confidence is absent — + avoiding developer-visible failed local attempts. + +### Modified Capabilities + +- `cli-help`: Register the four routing topics (`route`, `existing`, `static`, + `remote`) in the help index and emit their intent telemetry, consistent with + existing topic registration and embedding requirements. +- `skill-taskless`: Reverse the named-tool suppression clause — when a user names + a linter or asks to author a rule, the skill routes through `taskless help +route` instead of quieting. The skill stays a thin router and adds no rule + knowledge of its own. **Hard constraint:** the skill `description` field has a + 1024-character ceiling (Agent Skills spec). Reversing the suppression clause and + adding routing trigger language compete for that budget, so trigger wording must + be _tightened_ as this and future capabilities land — not appended. Treat the + 1024-char limit as a gating check whenever the description changes. + +## Impact + +- **New command**: `packages/cli/src/commands/detect.ts`, registered in + `packages/cli/src/index.ts`; a `detect` output schema under `schemas/`. +- **New help recipes**: `packages/cli/src/help/{route,existing,static,remote}.txt`, + embedded via the existing `import.meta.glob` build step. +- **Skill body + description**: `skills/taskless/SKILL.md` routing/trigger text. +- **Reused, unchanged**: `rule create` (remote backend for `remote`), `rule +verify` (the local verification gate), the canonical on-disk rule shape — remote + and local paths already write the same files/paths, so `check`/`improve`/`verify` + see one dialect. +- **Upstream dependency**: the Runtime Rules project (TSKL `Runtime Rules`) owns + static-vs-runtime; this change references it and never redefines it. +- **No new network dependencies**; `detect` and the local authoring paths are + offline-capable. Only `remote` requires auth. diff --git a/openspec/changes/local-rule-routing/specs/cli-detect/spec.md b/openspec/changes/local-rule-routing/specs/cli-detect/spec.md new file mode 100644 index 00000000..135d74bc --- /dev/null +++ b/openspec/changes/local-rule-routing/specs/cli-detect/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: Detect subcommand exists + +The CLI SHALL provide a `taskless detect` subcommand registered in the top-level +command list, with a `--json` flag and the standard `--dir`/`-d` working-directory +flag. + +#### Scenario: Detect is registered + +- **WHEN** `taskless detect --help` is run +- **THEN** the command SHALL be recognized and print its usage +- **AND** the command SHALL accept `--json` and `--dir`/`-d` + +### Requirement: Detect scans deterministic repo signals only + +The `detect` command SHALL emit only deterministic signals derived from files on +disk: configured linters, detected languages/frameworks, and the styles of the +repo's own existing rules. It SHALL NOT perform any LLM inference and SHALL NOT +match the request against any catalog of known packaged linter rules. + +#### Scenario: Linter configs are detected from disk + +- **WHEN** the working directory contains a recognized linter config (for + example `.eslintrc*`, `eslint.config.js`, `ruff.toml`, a `[tool.ruff]` block in + `pyproject.toml`, `.rubocop.yml`, `biome.json`, or `stylelint` config) +- **THEN** `detect --json` SHALL report each configured linter it found + +#### Scenario: Languages and frameworks are reported + +- **WHEN** `detect --json` runs in a repository +- **THEN** the output SHALL include the languages and frameworks inferred from + manifest and source signals present on disk + +#### Scenario: The repo's own rule styles are surfaced + +- **WHEN** the working directory contains existing rule definitions (for example + custom linter rules or `.taskless/rules/`) +- **THEN** `detect --json` SHALL surface a description of those existing rule + styles for downstream authoring + +#### Scenario: No packaged-rule catalog matching + +- **WHEN** `detect --json` runs +- **THEN** the output SHALL NOT claim a request maps to a specific named packaged + rule (such matching is left to the authoring recipe, not the command) + +### Requirement: Detect runs offline with no network or auth + +The `detect` command SHALL complete without network access and without +authentication. + +#### Scenario: Detect works without login or network + +- **WHEN** `detect --json` runs while logged out and offline +- **THEN** it SHALL produce its signal output successfully +- **AND** it SHALL NOT require or prompt for authentication + +### Requirement: Detect emits a stable JSON shape + +When `--json` is set, `detect` SHALL emit a single structured JSON object whose +shape is validated by a published output schema, consistent with other +`--json` commands in the CLI. + +#### Scenario: JSON output validates against the schema + +- **WHEN** `detect --json` succeeds +- **THEN** stdout SHALL be a single JSON object conforming to the detect output + schema (linters, languages/frameworks, existing rule styles) diff --git a/openspec/changes/local-rule-routing/specs/cli-help/spec.md b/openspec/changes/local-rule-routing/specs/cli-help/spec.md new file mode 100644 index 00000000..113308b6 --- /dev/null +++ b/openspec/changes/local-rule-routing/specs/cli-help/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: Routing topics are registered in the help system + +The help system SHALL register the routing recipes `route`, `existing`, `static`, +and `remote` as embedded help topics, retrievable via `taskless help ` and +listed in the help index, consistent with the existing topic embedding and format +requirements. + +#### Scenario: Routing topics resolve + +- **WHEN** `taskless help route`, `taskless help existing`, `taskless help +static`, or `taskless help remote` is run +- **THEN** the corresponding recipe text SHALL be returned +- **AND** an unknown-topic error SHALL NOT be raised for any of the four + +#### Scenario: Routing topics appear in the index + +- **WHEN** `taskless help` (no arguments) is run +- **THEN** the topic index SHALL include the routing topics so an agent can + discover the authoring front door + +### Requirement: Routing topics emit intent telemetry + +Fetching a routing recipe SHALL emit a per-topic intent telemetry event, +consistent with the existing `help_` telemetry convention. + +#### Scenario: Help topic intent is captured for routing recipes + +- **WHEN** the agent fetches `route`, `existing`, `static`, or `remote` +- **THEN** the help command SHALL capture the corresponding `help_` intent + event with the topic name diff --git a/openspec/changes/local-rule-routing/specs/cli-rule-routing/spec.md b/openspec/changes/local-rule-routing/specs/cli-rule-routing/spec.md new file mode 100644 index 00000000..72112d74 --- /dev/null +++ b/openspec/changes/local-rule-routing/specs/cli-rule-routing/spec.md @@ -0,0 +1,199 @@ +## ADDED Requirements + +### Requirement: Route is the local authoring classifier + +The CLI SHALL provide a `route` help recipe that instructs the agent to classify +a rule-authoring request into one of three destinations — `existing`, `static`, +or `remote` — using `taskless detect --json` signals plus the user's intent. The +`route` recipe SHALL be biased to stay local: it SHALL prefer `existing` or +`static` and SHALL treat `remote` as the escalation of last resort. + +#### Scenario: Route fetches detection before classifying + +- **WHEN** the agent fetches the `route` recipe to author a rule +- **THEN** the recipe SHALL direct the agent to run `taskless detect --json` and + use its signals as input to the classification + +#### Scenario: Route classifies into one of three destinations + +- **WHEN** the agent follows `route` +- **THEN** it SHALL select exactly one of `existing`, `static`, or `remote` +- **AND** it SHALL fetch the corresponding recipe to perform the authoring + +### Requirement: Route states reasoning before naming a destination + +The `route` recipe SHALL require the agent to write an explicit rationale before +naming a destination. The rationale SHALL cover what the `detect` signals show, +whether an existing linter plausibly already covers the request, whether the +pattern is expressible as a simple static ast-grep rule, and the resulting +confidence that the request is locally solvable. The destination SHALL be emitted +only after this rationale, and SHALL follow from it. + +#### Scenario: Rationale precedes the route decision + +- **WHEN** the agent follows `route` to classify a request +- **THEN** it SHALL produce a written rationale covering the detection signals, + existing-linter coverage, ast-grep expressibility, and local-solvability + confidence +- **AND** it SHALL name the destination (`existing`, `static`, or `remote`) only + after that rationale + +#### Scenario: Route is not named before reasoning + +- **WHEN** the agent has not yet articulated its reasoning +- **THEN** the recipe SHALL NOT permit committing to a destination +- **AND** the destination SHALL be a conclusion of the rationale, not asserted + ahead of it + +### Requirement: Route commits to the believed-correct path on reasonable confidence + +The `route` recipe SHALL determine the destination upfront from `detect` signals +and the user's intent, committing to the path it believes is correct. The bar to +commit to a local path SHALL be **reasonable confidence**, not certainty. When +`route` is not reasonably confident a request is locally solvable, it SHALL select +`remote` directly, without first attempting a local rule. The recipe SHALL NOT use +a deliberate local attempt-and-fail with no genuine belief of success as the +mechanism for choosing `remote`. + +#### Scenario: Reasonably-confident-local commits locally without a justification probe + +- **WHEN** `route` is reasonably confident the request fits an existing linter or + a simple static ast-grep pattern +- **THEN** it SHALL select `existing` or `static` and proceed locally +- **AND** it SHALL NOT run a throwaway local attempt whose only purpose is to + justify the choice + +#### Scenario: Not-reasonably-confident routes remote upfront + +- **WHEN** `route` is not reasonably confident the request is locally solvable +- **THEN** it SHALL select `remote` directly +- **AND** it SHALL NOT manufacture a deliberate local failure to reach that + decision + +#### Scenario: Uncertainty biases toward asking, not toward login + +- **WHEN** `route` cannot reasonably place a request as local or remote +- **THEN** it SHALL prefer clarifying with the user over defaulting to `remote` +- **AND** uncertainty alone SHALL NOT be treated as a reason to consume a + generation via `remote` + +### Requirement: A believed-local path that fails escalates only after confirmation + +The `route` recipe SHALL treat try-verify-escalate as a legitimate failure +fallback: when it committed to a local path on reasonable confidence and the +authored rule then fails verification against the user's success/failure cases, it +SHALL surface the failure and SHALL obtain explicit user confirmation before +calling the Taskless service. The recipe SHALL NOT silently fall through from a +failed local attempt to a service call. + +#### Scenario: Failed local attempt prompts before spending a generation + +- **WHEN** a `static` rule the agent committed to fails verification +- **THEN** the recipe SHALL inform the user the local rule could not capture the + cases +- **AND** SHALL state that generating via the Taskless service uses a generation + and requires login +- **AND** SHALL call the service only after the user confirms + +#### Scenario: No silent fall-through to the service + +- **WHEN** a believed-local attempt fails +- **THEN** the recipe SHALL NOT invoke `remote` / the service without an explicit + user confirmation step + +### Requirement: Route asks the user when multiple paths fit + +The `route` recipe SHALL present the viable options to the user with their +trade-offs, rather than silently selecting one, whenever more than one destination +genuinely fits a request (most commonly both `existing` and `static`). The +trade-off framing SHALL note that `remote` consumes a generation and requires +login, so it is appropriate when a request cannot be solved locally rather than as +a default. + +#### Scenario: Both local paths viable surfaces a choice + +- **WHEN** the repository has a detected linter that fits AND the pattern is a + clean local static ast-grep rule +- **THEN** `route` SHALL present both `existing` and `static` with their + trade-offs and let the user choose + +#### Scenario: Trade-off framing names the generation cost of remote + +- **WHEN** `route` presents options that include `remote` +- **THEN** it SHALL state that `remote` consumes a generation and requires login +- **AND** SHALL frame `remote` as the path for what cannot be solved locally + +### Requirement: Existing recipe authors in the detected linter's dialect + +The CLI SHALL provide an `existing` help recipe that instructs the agent to +author a rule in a linter already detected in the repository, expressed in that +tool's own dialect. The recipe SHALL direct the agent to source authoring +knowledge first from the repository's own existing rules and only then from the +agent's own web research. The recipe SHALL NOT embed or rely on a Taskless- +maintained catalog of linter rules. + +#### Scenario: Repo-first knowledge sourcing + +- **WHEN** the agent follows `existing` for a detected linter +- **THEN** it SHALL first mine the repository's existing rules of that kind for + house style +- **AND** SHALL fall back to web research (WebFetch/WebSearch) only when the + repository signal is insufficient + +#### Scenario: Existing path is author-only + +- **WHEN** the agent authors a rule via `existing` +- **THEN** the recipe SHALL make clear the user's own toolchain runs the rule and + that `taskless check` does not execute the external linter + +### Requirement: Static recipe authors a verified local ast-grep rule + +The CLI SHALL provide a `static` help recipe that instructs the agent to author a +local ast-grep rule on-device, without calling the Taskless service, and to +verify it against the user's success and failure cases before reporting success. +The recipe SHALL produce the canonical on-disk rule shape and paths used by remote +generation so that `check`, `improve`, and `verify` see a single dialect. + +#### Scenario: Local authoring without the service + +- **WHEN** the agent follows `static` +- **THEN** it SHALL write the rule on-device without requiring login or the + Taskless API + +#### Scenario: Verification gates success + +- **WHEN** the agent authors a static rule +- **THEN** it SHALL verify the rule against the provided success/failure cases + before reporting the rule as complete + +#### Scenario: Canonical output shape + +- **WHEN** the agent writes a static rule to disk +- **THEN** the files, paths, and shape SHALL match those produced by remote + generation + +### Requirement: Remote recipe collects inputs and delegates to the service + +The CLI SHALL provide a `remote` help recipe that instructs the agent to gather +the inputs required to call the Taskless service and to invoke the existing rule +generation backend, which runs the service-side classifier and returns either a +static or a runtime rule. The `remote` recipe SHALL require authentication and +SHALL NOT itself decide static versus runtime. + +#### Scenario: Remote requires authentication + +- **WHEN** the agent follows `remote` while logged out +- **THEN** the recipe SHALL direct the agent to the authentication flow before + submitting the request + +#### Scenario: Static-versus-runtime is decided by the service + +- **WHEN** the agent submits an authored request via `remote` +- **THEN** the recipe SHALL rely on the service to classify static versus runtime +- **AND** SHALL NOT make that determination locally + +#### Scenario: Remote output matches local on-disk shape + +- **WHEN** the service returns a generated rule via `remote` +- **THEN** the written files and paths SHALL match the shape produced by the + local `static` path diff --git a/openspec/changes/local-rule-routing/specs/skill-taskless/spec.md b/openspec/changes/local-rule-routing/specs/skill-taskless/spec.md new file mode 100644 index 00000000..67a31669 --- /dev/null +++ b/openspec/changes/local-rule-routing/specs/skill-taskless/spec.md @@ -0,0 +1,57 @@ +## MODIFIED Requirements + +### Requirement: Skill description anchors triggers on Taskless-specific phrases + +The consolidated skill's `description` frontmatter field SHALL anchor triggers on: + +1. An explicit reference to "Taskless" in the user's message, OR +2. A reference to the `.taskless/` directory or files within it (rules, rule-tests, rule-metadata), OR +3. A request to add/write/create a rule for code, including requests that name a specific lint/format/static-analysis tool (for example eslint, ruff, biome, stylelint, ast-grep). Naming such a tool SHALL engage the skill's routing flow rather than suppress it; the skill routes the request toward the appropriate authoring destination via `taskless help route`. + +The description SHALL NOT instruct the agent to suppress or quiet itself merely because a lint/format/static-analysis tool is named, and SHALL NOT contain a blanket "do NOT trigger on generic linting" instruction. + +#### Scenario: Description includes anchored trigger phrases + +- **WHEN** the skill `description` field is read +- **THEN** it SHALL include trigger phrases such as "create/add/write a taskless rule", "improve/fix/iterate on this taskless rule", "run taskless", "taskless login", "add taskless to CI" +- **AND** SHALL include a rule-authoring clause covering "add/write/create a rule" for code + +#### Scenario: Naming a linter engages routing rather than suppressing + +- **WHEN** the user asks to add/write/create a rule and names a specific lint/format/static-analysis tool (for example "write an eslint rule for X") +- **THEN** the skill SHALL trigger and route the request via `taskless help route` +- **AND** SHALL NOT quiet itself to a one-line offer on the basis of the named tool + +#### Scenario: Description omits suppression and the prior blanket carve-out + +- **WHEN** the skill `description` field is read +- **THEN** it SHALL NOT contain wording instructing the agent to suppress on a named lint/format/static-analysis tool +- **AND** it SHALL NOT contain wording instructing the agent to never trigger on generic ESLint/linting requests + +#### Scenario: Description is at most 1024 characters + +- **WHEN** the skill `description` field length is measured +- **THEN** it SHALL be at most 1024 characters (Agent Skills spec limit) + +## ADDED Requirements + +### Requirement: Skill routes authoring requests through the routing front door + +The skill body SHALL route rule-authoring requests through `taskless help route` +as the authoring front door, rather than fetching `rule create` directly. The +skill SHALL add no linter knowledge of its own and SHALL remain a thin router that +defers all authoring judgment to the fetched recipes. + +#### Scenario: Authoring requests are routed via route + +- **WHEN** the user asks to author a rule (with or without naming a tool) +- **THEN** the skill body SHALL direct the agent to fetch `taskless help route` + before any login-gated path +- **AND** SHALL NOT embed linter-specific rule knowledge in the skill body + +#### Scenario: Local-first before login + +- **WHEN** the routing recipe has not yet demonstrated that a rule cannot be built + locally +- **THEN** the skill SHALL NOT direct the agent toward a login-gated authoring + path From 9e4d3cbd98c6bf9ae1390e2e6a7d7c1b73df655a Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 16:03:28 -0700 Subject: [PATCH 02/28] feat(cli): Add deterministic detect command for rule routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `taskless detect --json`: an offline, deterministic scan of the repo for configured linters (config files, pyproject tool tables, package deps), inferred languages/frameworks, and the repo's own rule styles. No LLM, no network, no auth — it emits stable signal JSON to feed the routing recipe. Includes a focused scanner module, output schema, and integration tests covering detection, language/framework inference, no-packaged-rule-claims, and the offline/no-auth contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- openspec/changes/local-rule-routing/tasks.md | 39 +++ packages/cli/src/commands/detect.ts | 80 ++++++ packages/cli/src/detect/scan.ts | 276 +++++++++++++++++++ packages/cli/src/index.ts | 2 + packages/cli/src/schemas/detect.ts | 42 +++ packages/cli/test/detect.test.ts | 154 +++++++++++ 6 files changed, 593 insertions(+) create mode 100644 openspec/changes/local-rule-routing/tasks.md create mode 100644 packages/cli/src/commands/detect.ts create mode 100644 packages/cli/src/detect/scan.ts create mode 100644 packages/cli/src/schemas/detect.ts create mode 100644 packages/cli/test/detect.test.ts diff --git a/openspec/changes/local-rule-routing/tasks.md b/openspec/changes/local-rule-routing/tasks.md new file mode 100644 index 00000000..db5cb431 --- /dev/null +++ b/openspec/changes/local-rule-routing/tasks.md @@ -0,0 +1,39 @@ +## 1. Detect command (cli-detect) + +- [x] 1.1 Add a `detect` output schema under `packages/cli/src/schemas/` (linters, languages/frameworks, existing rule styles) +- [x] 1.2 Implement `packages/cli/src/commands/detect.ts`: deterministic, offline scan of linter configs, languages/frameworks, and the repo's own rule styles — no LLM, no network, no auth +- [x] 1.3 Register `detect` in `packages/cli/src/index.ts` subCommands with `--json` and `--dir`/`-d` +- [x] 1.4 Emit `cli_detect` telemetry consistent with other commands +- [x] 1.5 Add unit tests covering: eslint/ruff/rubocop/biome/stylelint config detection, language/framework inference, repo-rule-style surfacing, and JSON-shape validation against the schema +- [x] 1.6 Add a test asserting `detect` produces no packaged-rule-catalog claims and runs without network/auth + +## 2. Routing recipes (cli-rule-routing) + +- [ ] 2.1 Author `packages/cli/src/help/route.txt`: run `detect`; require the agent to WRITE its rationale first (detect signals, existing-linter coverage, ast-grep expressibility, local-solvability confidence) and name a destination only as a conclusion of that rationale; commit to the believed-correct path on REASONABLE confidence (existing/static/remote), route remote directly when not reasonably confident; when multiple paths fit, ask the user and explain trade-offs (note `remote` consumes a generation + needs login); never use a deliberate fail-first probe to select `remote` +- [ ] 2.1a In `route.txt`, specify the try-verify-escalate FALLBACK: when a believed-local `static` path fails verification, inform the user the local rule couldn't capture the cases and PROMPT-AND-CONFIRM before calling the service — never silently fall through to `remote` +- [ ] 2.2 Author `packages/cli/src/help/existing.txt`: author in the detected linter's dialect; repo-first knowledge sourcing then WebFetch; explicit author-only (user's toolchain runs it; `taskless check` does not run the external linter) +- [ ] 2.3 Author `packages/cli/src/help/static.txt`: local ast-grep authoring with verification against success/failure cases; canonical on-disk shape and paths; working candidate written under `.taskless/.tmp-*` with guaranteed cleanup on BOTH success and failure (mirror the `rule create` pattern); on verification failure, hand back to the `route` prompt-and-confirm fallback rather than escalating directly +- [ ] 2.4 Author `packages/cli/src/help/remote.txt`: collect inputs, require auth, invoke the existing `rule create` backend; service decides static vs runtime; never decide that locally +- [ ] 2.5 Ensure all four recipes follow the embedded help-text format (header, sprintf escaping) and reference `detect`/`route` consistently + +## 3. Help registration + telemetry (cli-help) + +- [ ] 3.1 Confirm the four `.txt` recipes are picked up by the `import.meta.glob` embedding and resolve via `taskless help ` +- [ ] 3.2 Ensure `route`, `existing`, `static`, `remote` appear in the `taskless help` (no-arg) topic index +- [ ] 3.3 Verify `help_` intent telemetry fires for each routing topic +- [ ] 3.4 Add tests for topic resolution, index listing, and telemetry capture + +## 4. Skill routing posture (skill-taskless) + +- [ ] 4.1 Update `skills/taskless/SKILL.md` `description`: replace the named-tool suppression clause so naming a linter engages routing via `taskless help route`; tighten (reword shorter) rather than append trigger text +- [ ] 4.1a Measure the resulting `description` length and assert it is ≤ 1024 chars (Agent Skills ceiling); treat overflow as a blocking failure and trim trigger wording until it fits +- [ ] 4.2 Update the skill body to route authoring requests through `taskless help route` (not `rule create` directly); remove the "quiet suggestion" suppression path; keep the skill a thin router with no linter knowledge +- [ ] 4.3 Bump the skill `metadata.version` per the file conventions +- [ ] 4.4 Verify the skill change against the updated `skill-taskless` scenarios (routing on named tool, no suppression wording, local-first before login) + +## 5. Validation + quality gate + +- [ ] 5.1 Run `pnpm openspec validate local-rule-routing` and resolve any issues +- [ ] 5.2 Add/curate the honesty eval fixtures (labeled request → expected route) and assert the `route` heuristic against both failure directions: under-confident (escalating a locally-solvable request to login) and over-confident (claiming local for a request that needs the service). Use the fixtures to calibrate the "confident enough for local" threshold +- [ ] 5.3 Run `pnpm typecheck` and `pnpm lint`; fix all failures +- [ ] 5.4 Manual smoke: `taskless detect --json`, then `taskless help route`/`existing`/`static`/`remote` resolve and read coherently end-to-end diff --git a/packages/cli/src/commands/detect.ts b/packages/cli/src/commands/detect.ts new file mode 100644 index 00000000..adbe1d5c --- /dev/null +++ b/packages/cli/src/commands/detect.ts @@ -0,0 +1,80 @@ +import { resolve } from "node:path"; + +import { defineCommand } from "citty"; + +import { detectRepository } from "../detect/scan"; +import { outputSchema as detectOutputSchema } from "../schemas/detect"; +import { getTelemetry } from "../telemetry"; +import { makeErrorEnvelope } from "../types/errors"; + +export const detectCommand = defineCommand({ + meta: { + name: "detect", + description: + "Scan the repo for configured linters, languages/frameworks, and existing rule styles (offline, deterministic)", + }, + args: { + dir: { + type: "string", + alias: "d", + description: "Working directory", + }, + json: { + type: "boolean", + description: "Output as JSON", + default: false, + }, + }, + async run({ args }) { + const cwd = resolve(args.dir ?? process.cwd()); + const telemetry = await getTelemetry(cwd); + telemetry.capture("cli_detect"); + + const result = { + success: true as const, + ...(await detectRepository(cwd)), + }; + + if (args.json) { + const parsed = detectOutputSchema.safeParse(result); + if (!parsed.success) { + console.log( + JSON.stringify( + makeErrorEnvelope( + "INTERNAL_ERROR", + "Internal schema validation failed" + ) + ) + ); + process.exitCode = 1; + return; + } + console.log(JSON.stringify(parsed.data)); + return; + } + + // Human-readable output + if (result.linters.length === 0) { + console.log("Linters: none detected"); + } else { + console.log("Linters:"); + for (const linter of result.linters) { + console.log(` ${linter.name}: ${linter.configFiles.join(", ")}`); + } + } + + console.log( + `\nLanguages: ${result.languages.length > 0 ? result.languages.join(", ") : "none detected"}` + ); + console.log( + `Frameworks: ${result.frameworks.length > 0 ? result.frameworks.join(", ") : "none detected"}` + ); + + if (result.ruleStyles.length > 0) { + console.log("\nExisting rule styles:"); + for (const style of result.ruleStyles) { + console.log(` ${style.source}: ${style.description}`); + } + } + }, +}); diff --git a/packages/cli/src/detect/scan.ts b/packages/cli/src/detect/scan.ts new file mode 100644 index 00000000..ea2e9a64 --- /dev/null +++ b/packages/cli/src/detect/scan.ts @@ -0,0 +1,276 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +export interface DetectedLinter { + name: string; + configFiles: string[]; +} + +export interface RuleStyle { + source: string; + description: string; +} + +export interface DetectResult { + linters: DetectedLinter[]; + languages: string[]; + frameworks: string[]; + ruleStyles: RuleStyle[]; +} + +/** + * A linter is evidenced by any of: + * - a fixed config filename present on disk + * - a `[tool.]` table in pyproject.toml + * - a dependency name in package.json (dev/prod/peer) + * + * The list is curated to well-known tools. New entries are deterministic + * signal additions, not inference — detect never matches a request against a + * catalog of packaged rules; that judgment lives in the `existing` recipe. + */ +interface LinterSignal { + name: string; + configFiles?: string[]; + pyprojectTables?: string[]; + packageDeps?: string[]; +} + +const LINTER_SIGNALS: readonly LinterSignal[] = [ + { + name: "eslint", + configFiles: [ + ".eslintrc", + ".eslintrc.js", + ".eslintrc.cjs", + ".eslintrc.mjs", + ".eslintrc.json", + ".eslintrc.yml", + ".eslintrc.yaml", + "eslint.config.js", + "eslint.config.mjs", + "eslint.config.cjs", + "eslint.config.ts", + ], + packageDeps: ["eslint"], + }, + { + name: "biome", + configFiles: ["biome.json", "biome.jsonc"], + packageDeps: ["@biomejs/biome"], + }, + { + name: "stylelint", + configFiles: [ + ".stylelintrc", + ".stylelintrc.js", + ".stylelintrc.cjs", + ".stylelintrc.json", + ".stylelintrc.yml", + ".stylelintrc.yaml", + "stylelint.config.js", + "stylelint.config.cjs", + "stylelint.config.mjs", + ], + packageDeps: ["stylelint"], + }, + { + name: "prettier", + configFiles: [ + ".prettierrc", + ".prettierrc.js", + ".prettierrc.cjs", + ".prettierrc.json", + ".prettierrc.yml", + ".prettierrc.yaml", + "prettier.config.js", + "prettier.config.cjs", + "prettier.config.mjs", + ], + packageDeps: ["prettier"], + }, + { + name: "ruff", + configFiles: ["ruff.toml", ".ruff.toml"], + pyprojectTables: ["ruff"], + }, + { name: "flake8", configFiles: [".flake8"] }, + { + name: "pylint", + configFiles: [".pylintrc", "pylintrc"], + pyprojectTables: ["pylint"], + }, + { name: "black", pyprojectTables: ["black"] }, + { name: "rubocop", configFiles: [".rubocop.yml", ".rubocop.yaml"] }, + { name: "clang-tidy", configFiles: [".clang-tidy"] }, + { name: "swiftlint", configFiles: [".swiftlint.yml", ".swiftlint.yaml"] }, + { name: "checkstyle", configFiles: ["checkstyle.xml"] }, +]; + +/** Languages inferred from the presence of a manifest or marker file. */ +const LANGUAGE_MARKERS: ReadonlyArray<{ language: string; files: string[] }> = [ + { + language: "Python", + files: [ + "pyproject.toml", + "requirements.txt", + "setup.cfg", + "setup.py", + "Pipfile", + ], + }, + { language: "Ruby", files: ["Gemfile"] }, + { language: "Go", files: ["go.mod"] }, + { language: "Rust", files: ["Cargo.toml"] }, + { language: "PHP", files: ["composer.json"] }, + { language: "Java", files: ["pom.xml", "build.gradle", "build.gradle.kts"] }, + { language: "Swift", files: ["Package.swift"] }, +]; + +/** Frameworks inferred from a package.json dependency name. */ +const JS_FRAMEWORK_DEPS: ReadonlyArray<{ framework: string; dep: string }> = [ + { framework: "Next.js", dep: "next" }, + { framework: "React", dep: "react" }, + { framework: "Vue", dep: "vue" }, + { framework: "Nuxt", dep: "nuxt" }, + { framework: "Svelte", dep: "svelte" }, + { framework: "Angular", dep: "@angular/core" }, + { framework: "Express", dep: "express" }, + { framework: "Fastify", dep: "fastify" }, + { framework: "NestJS", dep: "@nestjs/core" }, +]; + +/** Frameworks inferred from a Python dependency token. */ +const PY_FRAMEWORK_TOKENS: ReadonlyArray<{ framework: string; token: string }> = + [ + { framework: "Django", token: "django" }, + { framework: "Flask", token: "flask" }, + { framework: "FastAPI", token: "fastapi" }, + ]; + +async function readFileSafe(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch { + return undefined; + } +} + +interface PackageJson { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + eslintConfig?: unknown; +} + +/** Collect all declared dependency names from a parsed package.json. */ +function allDependencyNames(packageJson: PackageJson): Set { + return new Set([ + ...Object.keys(packageJson.dependencies ?? {}), + ...Object.keys(packageJson.devDependencies ?? {}), + ...Object.keys(packageJson.peerDependencies ?? {}), + ]); +} + +/** + * Deterministically scan `cwd` for linter configs, languages/frameworks, and + * the repo's own rule styles. Pure filesystem reads — no network, no auth, no + * LLM. Unreadable or malformed files are skipped rather than failing the scan. + */ +export async function detectRepository(cwd: string): Promise { + const root = resolve(cwd); + const has = (name: string): boolean => existsSync(resolve(root, name)); + + const packageRaw = await readFileSafe(resolve(root, "package.json")); + let packageJson: PackageJson | undefined; + if (packageRaw) { + try { + packageJson = JSON.parse(packageRaw) as PackageJson; + } catch { + packageJson = undefined; + } + } + const deps = packageJson + ? allDependencyNames(packageJson) + : new Set(); + + const pyproject = (await readFileSafe(resolve(root, "pyproject.toml"))) ?? ""; + + // Linters + const linters: DetectedLinter[] = []; + for (const signal of LINTER_SIGNALS) { + const evidence: string[] = []; + for (const file of signal.configFiles ?? []) { + if (has(file)) evidence.push(file); + } + for (const table of signal.pyprojectTables ?? []) { + if (pyproject.includes(`[tool.${table}`)) + evidence.push(`pyproject.toml [tool.${table}]`); + } + for (const dep of signal.packageDeps ?? []) { + if (deps.has(dep)) evidence.push(`package.json (${dep})`); + } + // eslintConfig key in package.json is an additional eslint signal + if (signal.name === "eslint" && packageJson?.eslintConfig !== undefined) { + evidence.push("package.json (eslintConfig)"); + } + if (evidence.length > 0) { + linters.push({ name: signal.name, configFiles: evidence }); + } + } + + // Languages + const languages: string[] = []; + if (packageJson || has("tsconfig.json")) languages.push("JavaScript"); + if (has("tsconfig.json") || deps.has("typescript")) + languages.push("TypeScript"); + for (const marker of LANGUAGE_MARKERS) { + if (marker.files.some((f) => has(f))) languages.push(marker.language); + } + + // Frameworks + const frameworks: string[] = []; + for (const { framework, dep } of JS_FRAMEWORK_DEPS) { + if (deps.has(dep)) frameworks.push(framework); + } + const pyText = ( + pyproject + + "\n" + + ((await readFileSafe(resolve(root, "requirements.txt"))) ?? "") + ).toLowerCase(); + for (const { framework, token } of PY_FRAMEWORK_TOKENS) { + if (pyText.includes(token)) frameworks.push(framework); + } + + // Rule styles + const ruleStyles: RuleStyle[] = []; + if (existsSync(resolve(root, ".taskless", "rules"))) { + ruleStyles.push({ + source: ".taskless/rules", + description: + "Existing Taskless ast-grep rules — match their structure and conventions.", + }); + } + for (const directory of [ + "eslint-rules", + "eslint-local-rules", + "tools/eslint-rules", + ]) { + if (existsSync(resolve(root, directory))) { + ruleStyles.push({ + source: directory, + description: + "Custom ESLint rules — follow the house style when authoring new ones.", + }); + } + } + if (deps.has("eslint-plugin-local") || deps.has("eslint-local-rules")) { + ruleStyles.push({ + source: "package.json", + description: + "Local ESLint rule plugin in use — author new rules to match it.", + }); + } + + return { linters, languages, frameworks, ruleStyles }; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2b9060cd..df43fef2 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,6 +2,7 @@ import { defineCommand, runCommand, showUsage } from "citty"; import { authCommand } from "./commands/auth"; import { checkCommand } from "./commands/check"; +import { detectCommand } from "./commands/detect"; import { initCommand, updateCommand } from "./commands/init"; import { infoCommand } from "./commands/info"; import { createHelpCommand } from "./commands/help"; @@ -14,6 +15,7 @@ const subCommands = { init: initCommand, update: updateCommand, info: infoCommand, + detect: detectCommand, check: checkCommand, auth: authCommand, onboard: onboardCommand, diff --git a/packages/cli/src/schemas/detect.ts b/packages/cli/src/schemas/detect.ts new file mode 100644 index 00000000..6817d553 --- /dev/null +++ b/packages/cli/src/schemas/detect.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +/** A linter detected from configuration on disk */ +const detectedLinterSchema = z.object({ + name: z.string().describe("Linter identifier, e.g. eslint, ruff, rubocop"), + configFiles: z + .array(z.string()) + .describe("Repo-relative config paths that evidenced this linter"), +}); + +/** A surfaced style of the repo's own existing rules */ +const ruleStyleSchema = z.object({ + source: z + .string() + .describe("Where the existing rules live, e.g. .taskless/rules"), + description: z + .string() + .describe("How the repo authors rules of this kind, for downstream reuse"), +}); + +/** Output schema for `taskless detect --json` on success */ +export const outputSchema = z.object({ + success: z.literal(true), + linters: z + .array(detectedLinterSchema) + .describe("Linters configured in the working directory"), + languages: z + .array(z.string()) + .describe("Languages inferred from manifests and source signals"), + frameworks: z + .array(z.string()) + .describe("Frameworks inferred from dependency manifests"), + ruleStyles: z + .array(ruleStyleSchema) + .describe("Styles of the repo's own existing rules"), +}); + +/** Error schema for `taskless detect --json` on failure */ +export const errorSchema = z.object({ + success: z.literal(false), + error: z.string().describe("Error message"), +}); diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts new file mode 100644 index 00000000..59b3d7f4 --- /dev/null +++ b/packages/cli/test/detect.test.ts @@ -0,0 +1,154 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const execFileAsync = promisify(execFile); +const binPath = resolve(import.meta.dirname, "../dist/index.js"); + +interface ExecError extends Error { + stdout?: string; + stderr?: string; + code?: number; +} + +async function runCli( + args: string[], + cwd: string +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + try { + const { stdout, stderr } = await execFileAsync("node", [binPath, ...args], { + cwd, + }); + return { stdout, stderr, exitCode: 0 }; + } catch (error) { + const error_ = error as ExecError; + return { + stdout: error_.stdout ?? "", + stderr: error_.stderr ?? "", + exitCode: error_.code ?? 1, + }; + } +} + +interface DetectJson { + success: boolean; + linters: { name: string; configFiles: string[] }[]; + languages: string[]; + frameworks: string[]; + ruleStyles: { source: string; description: string }[]; +} + +async function detect(cwd: string): Promise { + const { stdout, exitCode } = await runCli( + ["detect", "--json", "-d", cwd], + cwd + ); + expect(exitCode).toBe(0); + return JSON.parse(stdout.trim()) as DetectJson; +} + +function linterNames(result: DetectJson): string[] { + return result.linters.map((l) => l.name); +} + +describe("taskless detect", () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "taskless-detect-")); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + it("detects eslint from a config file", async () => { + await writeFile(join(cwd, ".eslintrc.json"), "{}", "utf8"); + const result = await detect(cwd); + expect(linterNames(result)).toContain("eslint"); + }); + + it("detects ruff from a pyproject [tool.ruff] table", async () => { + await writeFile( + join(cwd, "pyproject.toml"), + "[tool.ruff]\nline-length = 88\n", + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).toContain("ruff"); + expect(result.languages).toContain("Python"); + }); + + it("detects rubocop from .rubocop.yml", async () => { + await writeFile(join(cwd, ".rubocop.yml"), "", "utf8"); + await writeFile(join(cwd, "Gemfile"), "", "utf8"); + const result = await detect(cwd); + expect(linterNames(result)).toContain("rubocop"); + expect(result.languages).toContain("Ruby"); + }); + + it("detects biome from biome.json", async () => { + await writeFile(join(cwd, "biome.json"), "{}", "utf8"); + const result = await detect(cwd); + expect(linterNames(result)).toContain("biome"); + }); + + it("detects stylelint from a package.json devDependency", async () => { + await writeFile( + join(cwd, "package.json"), + JSON.stringify({ devDependencies: { stylelint: "^16.0.0" } }), + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).toContain("stylelint"); + }); + + it("infers languages and frameworks from package.json", async () => { + await writeFile( + join(cwd, "package.json"), + JSON.stringify({ + dependencies: { react: "^18", next: "^14" }, + devDependencies: { typescript: "^5" }, + }), + "utf8" + ); + const result = await detect(cwd); + expect(result.languages).toEqual( + expect.arrayContaining(["JavaScript", "TypeScript"]) + ); + expect(result.frameworks).toEqual( + expect.arrayContaining(["React", "Next.js"]) + ); + }); + + it("surfaces the repo's own Taskless rule styles", async () => { + await mkdir(join(cwd, ".taskless", "rules"), { recursive: true }); + const result = await detect(cwd); + expect(result.ruleStyles.some((s) => s.source === ".taskless/rules")).toBe( + true + ); + }); + + it("emits a stable JSON shape with only signal keys (no packaged-rule claims)", async () => { + await writeFile(join(cwd, ".eslintrc.json"), "{}", "utf8"); + const result = await detect(cwd); + expect(Object.keys(result).toSorted()).toEqual( + ["frameworks", "languages", "linters", "ruleStyles", "success"].toSorted() + ); + // A linter entry exposes only name + config evidence, never a rule-name claim. + for (const linter of result.linters) { + expect(Object.keys(linter).toSorted()).toEqual( + ["configFiles", "name"].toSorted() + ); + } + }); + + it("runs successfully with no linters, no network, and no auth", async () => { + const result = await detect(cwd); + expect(result.success).toBe(true); + expect(result.linters).toEqual([]); + }); +}); From acec1fec49060167d9e770a71e27593555d0ceb6 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 16:10:03 -0700 Subject: [PATCH 03/28] feat(cli): Add route/existing/static/remote authoring recipes Add the four routing recipes the skill fetches as the rule-authoring front door. `route` runs detect, requires the agent to write its rationale before naming a destination, commits local on reasonable confidence, asks when multiple paths fit, and treats try-verify-escalate as a failure fallback that prompts-and-confirms before spending a generation. `existing` authors in a detected linter's dialect (repo-first knowledge, web fallback, author-only). `static` authors a verified local ast-grep rule in the canonical on-disk shape, cleaning up an abandoned candidate on escalation. `remote` collects inputs and delegates to the rule create backend, letting the service decide static vs runtime. Recipes are auto-embedded via the existing import.meta.glob step and all four resolve through `taskless help `. Co-Authored-By: Claude Opus 4.8 (1M context) --- openspec/changes/local-rule-routing/tasks.md | 12 +-- packages/cli/src/help/existing.txt | 57 ++++++++++++++ packages/cli/src/help/remote.txt | 59 ++++++++++++++ packages/cli/src/help/route.txt | 82 ++++++++++++++++++++ packages/cli/src/help/static.txt | 72 +++++++++++++++++ 5 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/help/existing.txt create mode 100644 packages/cli/src/help/remote.txt create mode 100644 packages/cli/src/help/route.txt create mode 100644 packages/cli/src/help/static.txt diff --git a/openspec/changes/local-rule-routing/tasks.md b/openspec/changes/local-rule-routing/tasks.md index db5cb431..bc2ec4e2 100644 --- a/openspec/changes/local-rule-routing/tasks.md +++ b/openspec/changes/local-rule-routing/tasks.md @@ -9,12 +9,12 @@ ## 2. Routing recipes (cli-rule-routing) -- [ ] 2.1 Author `packages/cli/src/help/route.txt`: run `detect`; require the agent to WRITE its rationale first (detect signals, existing-linter coverage, ast-grep expressibility, local-solvability confidence) and name a destination only as a conclusion of that rationale; commit to the believed-correct path on REASONABLE confidence (existing/static/remote), route remote directly when not reasonably confident; when multiple paths fit, ask the user and explain trade-offs (note `remote` consumes a generation + needs login); never use a deliberate fail-first probe to select `remote` -- [ ] 2.1a In `route.txt`, specify the try-verify-escalate FALLBACK: when a believed-local `static` path fails verification, inform the user the local rule couldn't capture the cases and PROMPT-AND-CONFIRM before calling the service — never silently fall through to `remote` -- [ ] 2.2 Author `packages/cli/src/help/existing.txt`: author in the detected linter's dialect; repo-first knowledge sourcing then WebFetch; explicit author-only (user's toolchain runs it; `taskless check` does not run the external linter) -- [ ] 2.3 Author `packages/cli/src/help/static.txt`: local ast-grep authoring with verification against success/failure cases; canonical on-disk shape and paths; working candidate written under `.taskless/.tmp-*` with guaranteed cleanup on BOTH success and failure (mirror the `rule create` pattern); on verification failure, hand back to the `route` prompt-and-confirm fallback rather than escalating directly -- [ ] 2.4 Author `packages/cli/src/help/remote.txt`: collect inputs, require auth, invoke the existing `rule create` backend; service decides static vs runtime; never decide that locally -- [ ] 2.5 Ensure all four recipes follow the embedded help-text format (header, sprintf escaping) and reference `detect`/`route` consistently +- [x] 2.1 Author `packages/cli/src/help/route.txt`: run `detect`; require the agent to WRITE its rationale first (detect signals, existing-linter coverage, ast-grep expressibility, local-solvability confidence) and name a destination only as a conclusion of that rationale; commit to the believed-correct path on REASONABLE confidence (existing/static/remote), route remote directly when not reasonably confident; when multiple paths fit, ask the user and explain trade-offs (note `remote` consumes a generation + needs login); never use a deliberate fail-first probe to select `remote` +- [x] 2.1a In `route.txt`, specify the try-verify-escalate FALLBACK: when a believed-local `static` path fails verification, inform the user the local rule couldn't capture the cases and PROMPT-AND-CONFIRM before calling the service — never silently fall through to `remote` +- [x] 2.2 Author `packages/cli/src/help/existing.txt`: author in the detected linter's dialect; repo-first knowledge sourcing then WebFetch; explicit author-only (user's toolchain runs it; `taskless check` does not run the external linter) +- [x] 2.3 Author `packages/cli/src/help/static.txt`: local ast-grep authoring with verification against success/failure cases; canonical on-disk shape and paths (`.taskless/rules/.yml` + rule-tests, so `rule verify ` can read it); on verification failure into the escalation fallback, guarantee cleanup of the abandoned candidate rule + test files (mirrors the `rule create` cleanup intent); hand back to the `route` prompt-and-confirm fallback rather than escalating directly +- [x] 2.4 Author `packages/cli/src/help/remote.txt`: collect inputs, require auth, invoke the existing `rule create` backend; service decides static vs runtime; never decide that locally +- [x] 2.5 Ensure all four recipes follow the embedded help-text format (header, sprintf escaping) and reference `detect`/`route` consistently ## 3. Help registration + telemetry (cli-help) diff --git a/packages/cli/src/help/existing.txt b/packages/cli/src/help/existing.txt new file mode 100644 index 00000000..efc00e53 --- /dev/null +++ b/packages/cli/src/help/existing.txt @@ -0,0 +1,57 @@ +# Topic: existing (CLI v%(CLI_VERSION)s / topic v1) + +## Goal +Author a rule in a linter the repository ALREADY uses, expressed in that +tool's own dialect (an ESLint rule, a Ruff rule selection, a RuboCop cop, +a Stylelint rule, etc.). Taskless does not maintain a catalog of linter +rules — you source the knowledge from the repo first and the web second, +then write the rule where that tool expects it. + +## Preconditions +- The repo has a detected linter (confirm via `taskless detect --json`). +- The agent can read/write files and fetch web pages. +- No auth required. + +## Steps + +1. **Confirm the target tool.** Use `taskless detect --json` to identify + which linter is configured. If more than one could host this rule, + ask the user which tool should own it. + +2. **Mine the repo's own rules first.** This is the highest-signal + source for house style. Look at how the repo already writes rules of + this kind: + - existing config (e.g. `.eslintrc*`/`eslint.config.*`, `ruff.toml` + or `[tool.ruff]`, `.rubocop.yml`, `.stylelintrc*`); + - any custom/local rules the repo authored (the `detect` output's + rule styles point at these); + - the conventions, severity choices, and naming they use. + Match that style. + +3. **Fall back to the web only if the repo signal is thin.** If the repo + doesn't show how to express this rule, fetch the linter's CURRENT + documentation (WebFetch/WebSearch). Prefer the latest version's docs + and, when present, an `llms.txt` on the tool's docs site. Confirm the + installed version where it matters so you target the right syntax. + +4. **Author the rule in the tool's dialect.** Write or extend the + linter's config / custom-rule file the way that tool expects. Keep it + consistent with the repo's existing entries from step 2. + +5. **Report, and be explicit about who runs it.** Show the file(s) you + changed. Make clear that the user's OWN toolchain runs this rule — + `taskless check` does NOT execute external linters. Tell the user how + to run their linter to see it fire (e.g. their existing lint script). + +## Important Notes + +- Do not invent linter rules from memory — verify against the repo's + usage and the tool's current docs. +- This path is author-only. Taskless does not aggregate or run external + linters; it writes the rule in the tool's dialect and hands off. + +## See Also + +- `taskless help route` — re-decide the destination if this no longer fits +- `taskless help static` — author a local ast-grep rule instead +- `taskless help remote` — generate via the Taskless service (login) diff --git a/packages/cli/src/help/remote.txt b/packages/cli/src/help/remote.txt new file mode 100644 index 00000000..0630b43d --- /dev/null +++ b/packages/cli/src/help/remote.txt @@ -0,0 +1,59 @@ +# Topic: remote (CLI v%(CLI_VERSION)s / topic v1) + +## Goal +Generate a rule using the Taskless service. The service runs the heavy +classifier and returns either a **static** ast-grep rule or a **runtime** +rule, depending on the request — you do NOT decide static vs runtime +here; that is the service's job. This path consumes a generation and +requires login. Reach it when a rule is not reasonably solvable locally, +or when a believed-local attempt failed and the user confirmed. + +## Preconditions +- `.taskless/` directory exists. +- The user is logged in (this path requires auth). +- The request has been routed here (see `taskless help route`), not + reached by skipping the local-first decision. + +## Steps + +1. **Confirm this is the right path.** You should be here because the + request is not reasonably solvable locally, OR a local `static` + attempt failed and the user confirmed spending a generation. If the + user has not confirmed an escalation from a failed local attempt, get + that confirmation first — the service costs a generation and login. + +2. **Confirm auth.** Run: + ``` + npx @taskless/cli info --json + ``` + Check `loggedIn`. If false, fetch `taskless help auth` and follow the + login recipe before continuing. + +3. **Gather the request.** Collect the rule description plus concrete + success and failure cases. If you arrived here from `static`, reuse + the cases you already gathered. The service uses these both to + classify (static vs runtime) and to generate. + +4. **Delegate to the generation backend.** Fetch `taskless help rule + create` and follow it. That recipe builds the request payload and + invokes `rule create`, which submits to the service and writes the + result. Do not re-implement the submission here. + +5. **Report results.** The service writes the generated rule to the same + on-disk paths and shape as a locally authored rule, so `check`, + `improve`, and `verify` treat it identically. Show the user the file + paths and suggest `taskless help check` to validate. + +## Important Notes + +- Do NOT classify static vs runtime yourself — submit the request and + let the service decide. +- This is the only path that can produce a runtime rule (the service + generates the `check.ts`); the local `static` path cannot. + +## See Also + +- `taskless help route` — the local-first routing decision +- `taskless help rule create` — the generation backend this path uses +- `taskless help auth` — log in before generating +- `taskless help check` — validate the generated rule diff --git a/packages/cli/src/help/route.txt b/packages/cli/src/help/route.txt new file mode 100644 index 00000000..eb5b0ebf --- /dev/null +++ b/packages/cli/src/help/route.txt @@ -0,0 +1,82 @@ +# Topic: route (CLI v%(CLI_VERSION)s / topic v1) + +## Goal +Decide where a rule-authoring request should be built: in a linter the +repo ALREADY uses (`existing`), as a local ast-grep rule on this machine +(`static`), or by the Taskless service (`remote`). This is the front +door for "author/write/create a rule" requests. You stay local whenever +you can reasonably build the rule on-device; you only send the user to +the login-gated service when you are not reasonably confident the rule +is locally solvable — or when a believed-local attempt has genuinely +failed and the user confirms. + +## Preconditions +- A working repository the agent can read. +- No auth required to route. (The `remote` destination requires login; + the `existing` and `static` destinations do not.) + +## Steps + +1. **Scan the repo.** Run: + ``` + npx @taskless/cli detect --json + ``` + This returns the configured linters, languages/frameworks, and the + repo's own rule styles. It is deterministic and offline — use it as + ground truth instead of guessing the repo's tooling. + +2. **Write your reasoning BEFORE naming a destination.** Do not pick a + route first and justify it after. Write a short rationale covering: + - what the `detect` signals show (linters, languages, repo rule styles); + - whether an existing linter plausibly already covers this request; + - whether the pattern is expressible as a simple static ast-grep rule; + - your resulting confidence that the request is locally solvable. + The destination you choose in step 3 MUST follow from this rationale. + +3. **Choose the destination as a conclusion of the rationale:** + + - **Both an existing linter AND a local static rule fit** → do NOT + silently pick one. Present both to the user with trade-offs and let + them choose. Note that `remote` consumes a Taskless generation and + requires login, so it is for what cannot be solved locally — not a + default. + - **An existing linter clearly fits** (the repo uses it and it can + express this) → fetch `taskless help existing`. + - **You are reasonably confident it is a simple static ast-grep + pattern** → fetch `taskless help static`. Reasonable confidence is + enough here; you do not need certainty, because step 4 backstops a + wrong-but-reasonable bet. + - **You are NOT reasonably confident it is locally solvable** → fetch + `taskless help remote`. Do not manufacture a deliberate local + failure to reach this; route here directly. + + If you cannot reasonably place the request as local or remote, ASK the + user a clarifying question. Uncertainty is a reason to ask, never a + reason to spend a generation on `remote`. + +4. **Failure fallback (try-verify-escalate).** If you committed to a + local `static` rule on reasonable confidence and it then FAILS + verification against the user's success/failure cases: + - Tell the user the local rule could not capture the cases. + - State that generating via the Taskless service uses a generation + and requires login. + - Call the service only after the user confirms. On yes, fetch + `taskless help remote`. + Never silently fall through from a failed local attempt to a service + call — the confirmation step is required. + +## Important Notes + +- Reason first, route second. Do not name a destination before the + rationale is written. +- Stay local when you reasonably can. `remote` is the path for what + cannot be solved on-device, and it costs a generation plus login. +- A developer who watches a local attempt fail reads it as a Taskless + failure. Only attempt local when you reasonably believe it will work; + otherwise route `remote` upfront. + +## See Also + +- `taskless help existing` — author in a linter the repo already uses +- `taskless help static` — author a local ast-grep rule (no login) +- `taskless help remote` — generate via the Taskless service (login) diff --git a/packages/cli/src/help/static.txt b/packages/cli/src/help/static.txt new file mode 100644 index 00000000..50b14511 --- /dev/null +++ b/packages/cli/src/help/static.txt @@ -0,0 +1,72 @@ +# Topic: static (CLI v%(CLI_VERSION)s / topic v1) + +## Goal +Author a Taskless ast-grep rule **locally**, on this machine, without +contacting the Taskless service. You derive the rule yourself, write it +in the canonical on-disk shape, and validate it with `rule verify` in a +feedback loop. The files you produce match exactly what the service +writes, so `check`, `improve`, and `verify` treat them identically. + +## Preconditions +- `.taskless/` directory exists. +- The agent can read/write files and run shell commands. +- No auth required. + +## Steps + +1. **Learn the ast-grep rule format.** Consult the ast-grep rule + reference at https://ast-grep.github.io/guide/rule-config.html for + valid fields and operators (`pattern`, `kind`, `regex`, + `any`/`all`/`has`/`inside`/`not`) and meta-variable syntax. + +2. **Gather and confirm the pattern.** Make sure you have concrete + success cases (code that should pass) and failure cases (code that + should be flagged), the target language, and any exceptions. Search + the codebase for real instances and confirm exclusions with the user. + +3. **Author the rule in the canonical shape.** Write the rule to + `.taskless/rules/.yml` with at minimum `id` (kebab-case), + `language`, `severity` (`error`/`warning`/`info`/`hint`), `message`, + and the `rule` object. Write tests to + `.taskless/rule-tests/-YYYYMMDD-test.yml` with `valid` and + `invalid` arrays (at least two of each). These paths and shape are the + same ones the service writes — do not invent a different layout. + +4. **Run the verify feedback loop.** Run: + ``` + npx @taskless/cli rule verify --json + ``` + - `success: true` → the rule passes. Go to step 5. + - `success: false` → read the per-layer errors (`schema`, + `requirements`, `tests`), fix the rule or tests, and re-run. Repeat + up to 3 times. + +5. **On success, report.** Show the rule and test file paths and a + one-line summary of what the rule detects. Suggest `taskless help + check` to validate against the broader codebase. + +6. **On failure, escalate via the route fallback — with confirmation.** + If after the feedback loop the rule still cannot capture the user's + cases, this is the try-verify-escalate fallback: + - Delete the candidate `.taskless/rules/.yml` and its test file so + the repo is not left with a broken rule (guaranteed cleanup of the + abandoned candidate). + - Tell the user the local rule could not capture the cases, and that + generating via the Taskless service uses a generation and requires + login. + - Only after the user confirms, fetch `taskless help remote` and + follow it. Do not call the service silently. + +## Important Notes + +- Do NOT make any HTTP requests to taskless.io on this path. +- Do NOT write to `.taskless/rule-metadata/` — local rules have no + metadata sidecar; they 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 6). + +## See Also + +- `taskless help route` — re-decide the destination +- `taskless help remote` — generate via the Taskless service (login) +- `taskless help check` — validate the new rule against the codebase From ef45b0ab8a70d70416438544bfaaf69b4950a737 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 16:20:51 -0700 Subject: [PATCH 04/28] feat(cli): Surface routing recipes in the help index List route/existing/static/remote under an "Authoring recipes" section of the `taskless help` no-arg index so an agent can discover the rule-authoring front door. The existing help command already emits help_ intent telemetry generically, so the routing topics inherit it. Add integration tests for index listing and topic resolution, plus an in-process test asserting help_route/existing/static/remote capture. Co-Authored-By: Claude Opus 4.8 (1M context) --- openspec/changes/local-rule-routing/tasks.md | 8 +-- packages/cli/src/commands/help.ts | 22 +++++++- packages/cli/test/help-extensions.test.ts | 31 +++++++++++ .../cli/test/help-routing-telemetry.test.ts | 55 +++++++++++++++++++ 4 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 packages/cli/test/help-routing-telemetry.test.ts diff --git a/openspec/changes/local-rule-routing/tasks.md b/openspec/changes/local-rule-routing/tasks.md index bc2ec4e2..4c464a02 100644 --- a/openspec/changes/local-rule-routing/tasks.md +++ b/openspec/changes/local-rule-routing/tasks.md @@ -18,10 +18,10 @@ ## 3. Help registration + telemetry (cli-help) -- [ ] 3.1 Confirm the four `.txt` recipes are picked up by the `import.meta.glob` embedding and resolve via `taskless help ` -- [ ] 3.2 Ensure `route`, `existing`, `static`, `remote` appear in the `taskless help` (no-arg) topic index -- [ ] 3.3 Verify `help_` intent telemetry fires for each routing topic -- [ ] 3.4 Add tests for topic resolution, index listing, and telemetry capture +- [x] 3.1 Confirm the four `.txt` recipes are picked up by the `import.meta.glob` embedding and resolve via `taskless help ` +- [x] 3.2 Ensure `route`, `existing`, `static`, `remote` appear in the `taskless help` (no-arg) topic index +- [x] 3.3 Verify `help_` intent telemetry fires for each routing topic +- [x] 3.4 Add tests for topic resolution, index listing, and telemetry capture ## 4. Skill routing posture (skill-taskless) diff --git a/packages/cli/src/commands/help.ts b/packages/cli/src/commands/help.ts index 71793854..5be1421d 100644 --- a/packages/cli/src/commands/help.ts +++ b/packages/cli/src/commands/help.ts @@ -50,6 +50,16 @@ function buildHelpMaps(): { const { helpMap, anonymousMap } = buildHelpMaps(); +// Help-only recipe topics (no backing subcommand) that should still be +// discoverable from the `taskless help` index. The rule-authoring front +// door (`route`) and its destinations live here so an agent can find them. +const RECIPE_TOPICS: ReadonlyArray<[string, string]> = [ + ["route", "Decide where to author a rule (existing/static/remote)"], + ["existing", "Author a rule in a linter the repo already uses"], + ["static", "Author a local ast-grep rule on this machine (no login)"], + ["remote", "Generate a rule via the Taskless service (login)"], +]; + // Topic → Zod input schema. When a recipe contains the %(INPUT_SCHEMA)s // placeholder, the help command substitutes the JSON Schema rendered // from this Zod source. @@ -173,11 +183,21 @@ export function createHelpCommand(subCommands: SubCommandsDef) { entries.push([name, description]); } - const maxLength = Math.max(...entries.map(([name]) => name.length)); + // Pad commands and recipe topics against a shared width so the two + // sections line up. + const maxLength = Math.max( + ...entries.map(([name]) => name.length), + ...RECIPE_TOPICS.map(([name]) => name.length) + ); for (const [name, description] of entries) { console.log(` ${name.padEnd(maxLength + 2)}${description}`); } + console.log("\nAuthoring recipes:"); + for (const [name, description] of RECIPE_TOPICS) { + console.log(` ${name.padEnd(maxLength + 2)}${description}`); + } + console.log( "\nAppend `--anonymous` to any rule/check command to skip the Taskless API" ); diff --git a/packages/cli/test/help-extensions.test.ts b/packages/cli/test/help-extensions.test.ts index 998dd553..10688669 100644 --- a/packages/cli/test/help-extensions.test.ts +++ b/packages/cli/test/help-extensions.test.ts @@ -58,6 +58,37 @@ describe("taskless help (no args)", () => { const result = await runCli(["help", "-d", cwd]); expect(result.stdout).toContain("--anonymous"); }); + + it("lists the routing recipe topics under Authoring recipes", async () => { + const result = await runCli(["help", "-d", cwd]); + expect(result.stdout).toContain("Authoring recipes:"); + for (const topic of ["route", "existing", "static", "remote"]) { + expect(result.stdout).toContain(topic); + } + }); +}); + +describe("taskless help ", () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "taskless-help-routing-")); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + it.each(["route", "existing", "static", "remote"])( + "resolves the %s recipe without an unknown-topic error", + async (topic) => { + const result = await runCli(["help", topic, "-d", cwd]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(`# Topic: ${topic}`); + expect(result.stdout).toContain("## Goal"); + expect(result.stderr).not.toContain("Unknown command"); + } + ); }); describe("taskless help ", () => { diff --git a/packages/cli/test/help-routing-telemetry.test.ts b/packages/cli/test/help-routing-telemetry.test.ts new file mode 100644 index 00000000..79437851 --- /dev/null +++ b/packages/cli/test/help-routing-telemetry.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Spy on the telemetry capture by mocking the telemetry module the help +// command imports. The factory is invoked lazily at import time, so the +// closure over `capture` resolves after initialization (same pattern as +// telemetry.test.ts mocking posthog-node). +const capture = vi.fn(); +vi.mock("../src/telemetry", () => ({ + getTelemetry: vi.fn(() => + Promise.resolve({ + capture, + shutdown: () => Promise.resolve(), + }) + ), + shutdownTelemetry: () => Promise.resolve(), +})); + +const { createHelpCommand } = await import("../src/commands/help"); + +interface RunnableCommand { + run: (context: { + args: { dir: string; anonymous: boolean }; + rawArgs: string[]; + }) => Promise; +} + +describe("help routing topics emit help_ intent telemetry", () => { + let logSpy: ReturnType; + + beforeEach(() => { + capture.mockClear(); + // Suppress the recipe text the command prints to stdout. + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it.each(["route", "existing", "static", "remote"])( + "captures help_%s", + async (topic) => { + const command = createHelpCommand({}) as unknown as RunnableCommand; + await command.run({ + args: { dir: process.cwd(), anonymous: false }, + rawArgs: ["help", topic], + }); + + expect(capture).toHaveBeenCalledWith( + `help_${topic}`, + expect.objectContaining({ topic }) + ); + } + ); +}); From 7a295874f23f26210278dd3633c8dac0687c8f53 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 16:34:13 -0700 Subject: [PATCH 05/28] feat(skill): Engage routing when a linter is named Reverse the named-tool suppression: naming a linter (eslint, ruff, biome, ast-grep) now ENGAGES the skill's routing flow via `taskless help route` instead of quieting it. Replace the "Quiet suggestion" section with a route-through-route body that forbids fetching `rule create` directly and adds no linter knowledge, and relax the install gate so the `existing` path (which only reads the repo) is not blocked when `.taskless/` is absent. Description measures 835/1024 chars. Skill version is build-locked to the package version, so a minor changeset drives the bump rather than a hand-edited mismatch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/local-rule-routing.md | 10 +++++ openspec/changes/local-rule-routing/tasks.md | 10 ++--- skills/taskless/SKILL.md | 46 ++++++++++---------- 3 files changed, 37 insertions(+), 29 deletions(-) create mode 100644 .changeset/local-rule-routing.md diff --git a/.changeset/local-rule-routing.md b/.changeset/local-rule-routing.md new file mode 100644 index 00000000..e123ec41 --- /dev/null +++ b/.changeset/local-rule-routing.md @@ -0,0 +1,10 @@ +--- +"@taskless/skills": minor +--- + +Add a local-first rule-routing layer. A new deterministic `taskless detect` +command plus `route`/`existing`/`static`/`remote` recipes let the agent author +rules in an existing linter or as a local ast-grep rule on-device, only +escalating to the login-gated service (with confirmation) when a rule cannot be +built locally. The skill now engages this routing flow when a user names a +linter instead of suppressing itself. diff --git a/openspec/changes/local-rule-routing/tasks.md b/openspec/changes/local-rule-routing/tasks.md index 4c464a02..f2662c3a 100644 --- a/openspec/changes/local-rule-routing/tasks.md +++ b/openspec/changes/local-rule-routing/tasks.md @@ -25,11 +25,11 @@ ## 4. Skill routing posture (skill-taskless) -- [ ] 4.1 Update `skills/taskless/SKILL.md` `description`: replace the named-tool suppression clause so naming a linter engages routing via `taskless help route`; tighten (reword shorter) rather than append trigger text -- [ ] 4.1a Measure the resulting `description` length and assert it is ≤ 1024 chars (Agent Skills ceiling); treat overflow as a blocking failure and trim trigger wording until it fits -- [ ] 4.2 Update the skill body to route authoring requests through `taskless help route` (not `rule create` directly); remove the "quiet suggestion" suppression path; keep the skill a thin router with no linter knowledge -- [ ] 4.3 Bump the skill `metadata.version` per the file conventions -- [ ] 4.4 Verify the skill change against the updated `skill-taskless` scenarios (routing on named tool, no suppression wording, local-first before login) +- [x] 4.1 Update `skills/taskless/SKILL.md` `description`: replace the named-tool suppression clause so naming a linter engages routing via `taskless help route`; tighten (reword shorter) rather than append trigger text +- [x] 4.1a Measure the resulting `description` length and assert it is ≤ 1024 chars (Agent Skills ceiling); treat overflow as a blocking failure and trim trigger wording until it fits — measured 835 chars +- [x] 4.2 Update the skill body to route authoring requests through `taskless help route` (not `rule create` directly); remove the "quiet suggestion" suppression path; keep the skill a thin router with no linter knowledge +- [x] 4.3 Bump the skill `metadata.version` per the file conventions — version is build-locked to package version (assertSkillVersions); added a `minor` changeset so the release path bumps both in lockstep rather than hand-editing a mismatch +- [x] 4.4 Verify the skill change against the updated `skill-taskless` scenarios (routing on named tool, no suppression wording, local-first before login) ## 5. Validation + quality gate diff --git a/skills/taskless/SKILL.md b/skills/taskless/SKILL.md index b5e56174..08b17c16 100644 --- a/skills/taskless/SKILL.md +++ b/skills/taskless/SKILL.md @@ -14,12 +14,10 @@ description: | - "add taskless to CI", "wire taskless into github actions" - "onboard with taskless", "set up taskless for this project" - Also trigger when the user asks to add/write/create a rule and has NOT - named a specific lint/format/static-analysis tool. Examples that suppress - this trigger (illustrative — any named tool of this kind suppresses): - eslint, ruff, biome, ast-grep. On this trigger, surface a quiet - single-line offer rather than launching a recipe; see the body's quiet - suggestion section. + Also trigger on any request to add/write/create a lint or code rule, + including ones that name a specific tool (eslint, ruff, biome, stylelint, + ast-grep). Naming a tool ENGAGES this skill's routing flow via + `taskless help route`; it does NOT suppress the skill. metadata: author: taskless version: 0.8.1 @@ -34,20 +32,33 @@ canonical recipes live behind `npx @taskless/cli help `. Always fetch the recipe first; do not improvise from prior knowledge — recipes change with each CLI version. -## First step: confirm Taskless is installed here +## Authoring a rule: always start at route -If the working directory does not contain a `.taskless/` directory, ask the -user to confirm they meant Taskless (vs. ESLint or another tool). If they -confirm, offer to run `npx @taskless/cli` to install. Otherwise, stop. +For any request to add/write/create a rule — whether or not the user names a +tool (eslint, ruff, biome, ast-grep) — fetch `npx @taskless/cli help route` +and follow it. Do NOT fetch `rule create` directly, and do NOT author from +your own linter knowledge. `route` runs `detect`, reasons about the request, +and decides whether the rule is built in an existing linter (`existing`), as a +local ast-grep rule (`static`), or via the Taskless service (`remote`) — and it +keeps the work local before any login. This skill is a thin router: all +authoring judgment lives in the fetched recipes. + +## Confirm Taskless is installed when a path needs it + +`route` and the `existing` path only read the repo, so they need no install. If +routing lands on a local Taskless rule (`static`) or the service (`remote`) and +the working directory has no `.taskless/` directory, offer to run +`npx @taskless/cli` to install. If the user only wanted help with their own +linter, the `existing` path needs nothing installed. ## Topics | User wants | Topic | | -------------------------- | ------------------------------------- | +| Author/create a rule | `npx @taskless/cli help route` | | First-time install | tell user to run `npx @taskless/cli` | | Update existing install | `npx @taskless/cli update` | | Discover candidate rules | `npx @taskless/cli help onboard` | -| Create a new rule | `npx @taskless/cli help rule create` | | Improve an existing rule | `npx @taskless/cli help rule improve` | | Delete a rule | `npx @taskless/cli help rule delete` | | Check code against rules | `npx @taskless/cli help check` | @@ -58,19 +69,6 @@ If the user's intent is ambiguous between two topics, run `npx @taskless/cli help` (no args) to see the disambiguation table, or ask the user. -## Quiet suggestion - -When you trigger because the user asked to add/write/create a rule and did -NOT name a specific tool (per the description's named-tool clause), DO NOT -launch into a full recipe. Instead surface a one-line offer such as: "I can -capture this as a Taskless rule if you want — say so, or I'll proceed with -." If the user accepts, fetch -`npx @taskless/cli help rule create` and follow it. If the user declines or -ignores the offer, proceed with what you would have done without this skill, -and DO NOT re-offer Taskless in the same conversation. The decline is sticky -within the conversation only — do NOT write any persistent decline state to -disk or to `.taskless/taskless.json`. - ## --anonymous Any rule/check command accepts `--anonymous` to skip the Taskless API and From 1ecf2a5b01127d026613b409968233c8fa166d9c Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 16:40:22 -0700 Subject: [PATCH 06/28] test(cli): Add route honesty eval fixtures and finalize change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a labeled request→route calibration dataset for the route recipe covering both failure directions: over-claim (Taskless grabbing a packaged/formatter job that should stay `existing`) and over-escalate (a locally-solvable request wrongly sent to the login-gated service that should stay `static`), plus genuine `remote` cases. The route decision is agent-made by following help/route.txt, so the coverage test guards the dataset's balance across routes and traps rather than running a code classifier. Closes out the change: openspec validate passes, full CLI suite green, detect + routing recipes smoke-tested end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) --- openspec/changes/local-rule-routing/tasks.md | 8 +- packages/cli/test/fixtures/route-eval.json | 79 ++++++++++++++++++++ packages/cli/test/route-eval.test.ts | 79 ++++++++++++++++++++ 3 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 packages/cli/test/fixtures/route-eval.json create mode 100644 packages/cli/test/route-eval.test.ts diff --git a/openspec/changes/local-rule-routing/tasks.md b/openspec/changes/local-rule-routing/tasks.md index f2662c3a..2ea9f27e 100644 --- a/openspec/changes/local-rule-routing/tasks.md +++ b/openspec/changes/local-rule-routing/tasks.md @@ -33,7 +33,7 @@ ## 5. Validation + quality gate -- [ ] 5.1 Run `pnpm openspec validate local-rule-routing` and resolve any issues -- [ ] 5.2 Add/curate the honesty eval fixtures (labeled request → expected route) and assert the `route` heuristic against both failure directions: under-confident (escalating a locally-solvable request to login) and over-confident (claiming local for a request that needs the service). Use the fixtures to calibrate the "confident enough for local" threshold -- [ ] 5.3 Run `pnpm typecheck` and `pnpm lint`; fix all failures -- [ ] 5.4 Manual smoke: `taskless detect --json`, then `taskless help route`/`existing`/`static`/`remote` resolve and read coherently end-to-end +- [x] 5.1 Run `pnpm openspec validate local-rule-routing` and resolve any issues +- [x] 5.2 Add/curate the honesty eval fixtures (labeled request → expected route) and assert the `route` heuristic against both failure directions: under-confident (escalating a locally-solvable request to login) and over-confident (claiming local for a request that needs the service). Use the fixtures to calibrate the "confident enough for local" threshold — dataset at `test/fixtures/route-eval.json` with a coverage test; route decision is agent-made (recipe-followed), so the test guards dataset balance across routes + both traps rather than running a code classifier +- [x] 5.3 Run `pnpm typecheck` and `pnpm lint`; fix all failures +- [x] 5.4 Manual smoke: `taskless detect --json`, then `taskless help route`/`existing`/`static`/`remote` resolve and read coherently end-to-end diff --git a/packages/cli/test/fixtures/route-eval.json b/packages/cli/test/fixtures/route-eval.json new file mode 100644 index 00000000..38813b2c --- /dev/null +++ b/packages/cli/test/fixtures/route-eval.json @@ -0,0 +1,79 @@ +{ + "description": "Honesty eval fixtures for the `route` recipe. Each case is a labeled request with the destination route SHOULD reach. `trap` names the failure direction the case guards against (or null for a clean case). These are a calibration dataset for evaluating the route recipe's decisions; the route decision itself is made by an agent following help/route.txt, so these are not asserted against a code classifier — the coverage test only checks the dataset stays balanced across routes and both failure directions.", + "routes": ["existing", "static", "remote"], + "traps": ["over-claim", "over-escalate", "under-engage"], + "cases": [ + { + "request": "warn on console.log", + "expected": "existing", + "trap": "over-claim", + "reason": "ESLint's packaged no-console already covers this; claiming it for a Taskless rule erodes trust where Taskless would never win." + }, + { + "request": "sort our imports", + "expected": "existing", + "trap": "over-claim", + "reason": "Formatter / eslint-plugin-import territory, not a semantic rule." + }, + { + "request": "disallow `any` in TypeScript", + "expected": "existing", + "trap": "over-claim", + "reason": "@typescript-eslint/no-explicit-any is a packaged rule; enable it rather than authoring." + }, + { + "request": "write a Ruff rule for X (repo has ruff.toml)", + "expected": "existing", + "trap": "under-engage", + "reason": "Naming a tool must ENGAGE routing into that tool, not suppress the skill." + }, + { + "request": "add an ESLint rule banning direct process.env reads outside config/", + "expected": "existing", + "trap": "under-engage", + "reason": "User named ESLint and wants a custom rule; author it in ESLint's dialect, mined from the repo's own rules." + }, + { + "request": "controllers must not import the db module directly", + "expected": "static", + "trap": null, + "reason": "Bespoke syntactic import ban, no packaged rule, cleanly expressible as a local ast-grep rule." + }, + { + "request": "never call fetch directly — use our http wrapper", + "expected": "static", + "trap": null, + "reason": "Call/import ban; classic local ast-grep pattern." + }, + { + "request": "enforce our feature-flag naming convention ff_*", + "expected": "static", + "trap": "over-escalate", + "reason": "Repo-specific identifier pattern is a simple local ast-grep rule; escalating to the service here wastes a generation and a login." + }, + { + "request": "flag string literals that look like API keys in source files", + "expected": "static", + "trap": "over-escalate", + "reason": "A regex-shaped syntactic match is locally solvable; do not route to remote just because it sounds security-ish." + }, + { + "request": "every API handler must parse its request body through a Zod schema before using it", + "expected": "remote", + "trap": null, + "reason": "Cross-statement intent (parse-before-use) that a single ast-grep pattern strains to express; let the service classify static vs runtime." + }, + { + "request": "flag any PR that adds a new env var without documenting it in the README", + "expected": "remote", + "trap": null, + "reason": "Cross-file, config-vs-docs consistency — needs a runtime check.ts the service generates." + }, + { + "request": "ensure every exported function in a route file has a matching test in __tests__", + "expected": "remote", + "trap": null, + "reason": "Cross-file correlation no static syntactic matcher expresses; service/runtime territory." + } + ] +} diff --git a/packages/cli/test/route-eval.test.ts b/packages/cli/test/route-eval.test.ts new file mode 100644 index 00000000..50466b87 --- /dev/null +++ b/packages/cli/test/route-eval.test.ts @@ -0,0 +1,79 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +// The route decision is made by an agent following help/route.txt, so this +// dataset is not run against a code classifier. The test guards the dataset +// itself: it must stay structurally valid and balanced across every route and +// both failure directions, so it remains a usable calibration set. + +interface EvalCase { + request: string; + expected: string; + trap: string | null; + reason: string; +} + +interface EvalFixtures { + routes: string[]; + traps: string[]; + cases: EvalCase[]; +} + +const fixtures = JSON.parse( + readFileSync(resolve(import.meta.dirname, "fixtures/route-eval.json"), "utf8") +) as EvalFixtures; + +describe("route honesty eval fixtures", () => { + it("declares the three routes and both failure-direction traps", () => { + expect(fixtures.routes).toEqual(["existing", "static", "remote"]); + // Over-claim = Taskless grabbing a packaged/formatter job; over-escalate = + // sending a locally-solvable request to the login-gated service. + expect(fixtures.traps).toEqual( + expect.arrayContaining(["over-claim", "over-escalate", "under-engage"]) + ); + }); + + it("every case is well-formed and uses a declared route/trap", () => { + expect(fixtures.cases.length).toBeGreaterThanOrEqual(10); + for (const c of fixtures.cases) { + expect(c.request.length).toBeGreaterThan(0); + expect(c.reason.length).toBeGreaterThan(0); + expect(fixtures.routes).toContain(c.expected); + if (c.trap !== null) { + expect(fixtures.traps).toContain(c.trap); + } + } + }); + + it("covers every route at least twice", () => { + for (const route of fixtures.routes) { + const count = fixtures.cases.filter((c) => c.expected === route).length; + expect(count, `route ${route} needs >= 2 cases`).toBeGreaterThanOrEqual( + 2 + ); + } + }); + + it("guards both failure directions: over-claim and over-escalate", () => { + const overClaim = fixtures.cases.filter((c) => c.trap === "over-claim"); + const overEscalate = fixtures.cases.filter( + (c) => c.trap === "over-escalate" + ); + // Over-claim cases must expect a non-`static` local route (Taskless should + // not have grabbed them); over-escalate cases must expect a local route + // (they should not have gone to remote). + expect(overClaim.length).toBeGreaterThan(0); + expect(overEscalate.length).toBeGreaterThan(0); + for (const c of overClaim) expect(c.expected).toBe("existing"); + for (const c of overEscalate) expect(c.expected).toBe("static"); + }); + + it("includes genuine remote cases that are not locally solvable", () => { + const remote = fixtures.cases.filter((c) => c.expected === "remote"); + expect(remote.length).toBeGreaterThanOrEqual(2); + // Genuine remote cases are clean (no trap) — they legitimately need the + // service, not a misroute being corrected. + expect(remote.every((c) => c.trap === null)).toBe(true); + }); +}); From e995d5046b5bea8fbd31a5fa6d93a0859ff67d30 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 17:04:36 -0700 Subject: [PATCH 07/28] docs(openspec): Address review feedback on local-rule-routing contract Resolve Copilot review on PR #23: - Drop the "provably cannot be built locally" framing in the proposal; it set a stricter bar than the confidence-gated contract. Login is suggested when route is not reasonably confident a rule is locally solvable, or when a confident local attempt fails and the user confirms. - Tighten the cli-rule-routing spec so routing distinguishes three states: confident-local, reasonable-belief-not-local (-> remote), and genuine uncertainty (-> ask). Mere uncertainty no longer reads as a remote trigger. - Reference the canonical `npx @taskless/cli help route` invocation in the skill-taskless spec instead of a bare `taskless` binary name. - Fix Markdown inline-code spans that were split across line breaks. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../changes/local-rule-routing/proposal.md | 23 +++++++++++-------- .../local-rule-routing/specs/cli-help/spec.md | 4 ++-- .../specs/cli-rule-routing/spec.md | 18 +++++++++------ .../specs/skill-taskless/spec.md | 8 +++---- 4 files changed, 30 insertions(+), 23 deletions(-) diff --git a/openspec/changes/local-rule-routing/proposal.md b/openspec/changes/local-rule-routing/proposal.md index f1778581..63e2c2ea 100644 --- a/openspec/changes/local-rule-routing/proposal.md +++ b/openspec/changes/local-rule-routing/proposal.md @@ -8,8 +8,9 @@ same time, the skill actively _suppresses_ itself when a user names a linter (eslint, ruff, biome), stepping back from exactly the requests where Taskless could help author the rule in that tool's own dialect. We want a local routing layer that keeps authoring on-device whenever possible, engages other linters -instead of deferring, and only suggests login when a rule provably cannot be -built locally. +instead of deferring, and suggests login only when it is not reasonably +confident a rule can be built locally — or when a confident local attempt has +failed and the user confirms spending a generation. ## What Changes @@ -19,7 +20,8 @@ built locally. - **NEW routing recipe layer** under `help`, replacing the rule-type-agnostic `rule create` entry as the front door for "author a rule": - `route` — the lightweight **local classifier**. Biased to stay local; - suggests login only when local authoring provably fails. + suggests login when it is not reasonably confident the rule is locally + solvable, or when a confident local attempt fails and the user confirms. - `existing` — author a rule in a linter already detected in the repo, in that tool's dialect, sourced from the repo's own rules plus the agent's WebFetch. - `static` — author a local ast-grep rule on-device, verified against the @@ -62,9 +64,10 @@ built locally. `remote`) in the help index and emit their intent telemetry, consistent with existing topic registration and embedding requirements. - `skill-taskless`: Reverse the named-tool suppression clause — when a user names - a linter or asks to author a rule, the skill routes through `taskless help -route` instead of quieting. The skill stays a thin router and adds no rule - knowledge of its own. **Hard constraint:** the skill `description` field has a + a linter or asks to author a rule, the skill routes through + `npx @taskless/cli help route` instead of quieting. The skill stays a thin + router and adds no rule knowledge of its own. **Hard constraint:** the skill + `description` field has a 1024-character ceiling (Agent Skills spec). Reversing the suppression clause and adding routing trigger language compete for that budget, so trigger wording must be _tightened_ as this and future capabilities land — not appended. Treat the @@ -77,10 +80,10 @@ route` instead of quieting. The skill stays a thin router and adds no rule - **New help recipes**: `packages/cli/src/help/{route,existing,static,remote}.txt`, embedded via the existing `import.meta.glob` build step. - **Skill body + description**: `skills/taskless/SKILL.md` routing/trigger text. -- **Reused, unchanged**: `rule create` (remote backend for `remote`), `rule -verify` (the local verification gate), the canonical on-disk rule shape — remote - and local paths already write the same files/paths, so `check`/`improve`/`verify` - see one dialect. +- **Reused, unchanged**: `rule create` (remote backend for `remote`), + `rule verify` (the local verification gate), the canonical on-disk rule + shape — remote and local paths already write the same files/paths, so + `check`/`improve`/`verify` see one dialect. - **Upstream dependency**: the Runtime Rules project (TSKL `Runtime Rules`) owns static-vs-runtime; this change references it and never redefines it. - **No new network dependencies**; `detect` and the local authoring paths are diff --git a/openspec/changes/local-rule-routing/specs/cli-help/spec.md b/openspec/changes/local-rule-routing/specs/cli-help/spec.md index 113308b6..926c0690 100644 --- a/openspec/changes/local-rule-routing/specs/cli-help/spec.md +++ b/openspec/changes/local-rule-routing/specs/cli-help/spec.md @@ -9,8 +9,8 @@ requirements. #### Scenario: Routing topics resolve -- **WHEN** `taskless help route`, `taskless help existing`, `taskless help -static`, or `taskless help remote` is run +- **WHEN** `taskless help route`, `taskless help existing`, + `taskless help static`, or `taskless help remote` is run - **THEN** the corresponding recipe text SHALL be returned - **AND** an unknown-topic error SHALL NOT be raised for any of the four diff --git a/openspec/changes/local-rule-routing/specs/cli-rule-routing/spec.md b/openspec/changes/local-rule-routing/specs/cli-rule-routing/spec.md index 72112d74..0c6cfce4 100644 --- a/openspec/changes/local-rule-routing/specs/cli-rule-routing/spec.md +++ b/openspec/changes/local-rule-routing/specs/cli-rule-routing/spec.md @@ -49,11 +49,14 @@ only after this rationale, and SHALL follow from it. The `route` recipe SHALL determine the destination upfront from `detect` signals and the user's intent, committing to the path it believes is correct. The bar to -commit to a local path SHALL be **reasonable confidence**, not certainty. When -`route` is not reasonably confident a request is locally solvable, it SHALL select -`remote` directly, without first attempting a local rule. The recipe SHALL NOT use -a deliberate local attempt-and-fail with no genuine belief of success as the -mechanism for choosing `remote`. +commit to a local path SHALL be **reasonable confidence**, not certainty. Routing +distinguishes three states: reasonable confidence the request IS locally solvable +selects a local path; reasonable belief the request is NOT locally solvable selects +`remote` directly, without first attempting a local rule; genuine inability to +judge either way is uncertainty, which SHALL be resolved by asking the user (see +the clarifying-question scenario) and SHALL NOT by itself select `remote`. The +recipe SHALL NOT use a deliberate local attempt-and-fail with no genuine belief of +success as the mechanism for choosing `remote`. #### Scenario: Reasonably-confident-local commits locally without a justification probe @@ -63,9 +66,10 @@ mechanism for choosing `remote`. - **AND** it SHALL NOT run a throwaway local attempt whose only purpose is to justify the choice -#### Scenario: Not-reasonably-confident routes remote upfront +#### Scenario: Believed-not-local routes remote upfront -- **WHEN** `route` is not reasonably confident the request is locally solvable +- **WHEN** `route` reasonably believes the request cannot be solved locally — a + positive judgment, not mere inability to tell - **THEN** it SHALL select `remote` directly - **AND** it SHALL NOT manufacture a deliberate local failure to reach that decision diff --git a/openspec/changes/local-rule-routing/specs/skill-taskless/spec.md b/openspec/changes/local-rule-routing/specs/skill-taskless/spec.md index 67a31669..063ed1fd 100644 --- a/openspec/changes/local-rule-routing/specs/skill-taskless/spec.md +++ b/openspec/changes/local-rule-routing/specs/skill-taskless/spec.md @@ -6,7 +6,7 @@ The consolidated skill's `description` frontmatter field SHALL anchor triggers o 1. An explicit reference to "Taskless" in the user's message, OR 2. A reference to the `.taskless/` directory or files within it (rules, rule-tests, rule-metadata), OR -3. A request to add/write/create a rule for code, including requests that name a specific lint/format/static-analysis tool (for example eslint, ruff, biome, stylelint, ast-grep). Naming such a tool SHALL engage the skill's routing flow rather than suppress it; the skill routes the request toward the appropriate authoring destination via `taskless help route`. +3. A request to add/write/create a rule for code, including requests that name a specific lint/format/static-analysis tool (for example eslint, ruff, biome, stylelint, ast-grep). Naming such a tool SHALL engage the skill's routing flow rather than suppress it; the skill routes the request toward the appropriate authoring destination via `npx @taskless/cli help route`. The description SHALL NOT instruct the agent to suppress or quiet itself merely because a lint/format/static-analysis tool is named, and SHALL NOT contain a blanket "do NOT trigger on generic linting" instruction. @@ -19,7 +19,7 @@ The description SHALL NOT instruct the agent to suppress or quiet itself merely #### Scenario: Naming a linter engages routing rather than suppressing - **WHEN** the user asks to add/write/create a rule and names a specific lint/format/static-analysis tool (for example "write an eslint rule for X") -- **THEN** the skill SHALL trigger and route the request via `taskless help route` +- **THEN** the skill SHALL trigger and route the request via `npx @taskless/cli help route` - **AND** SHALL NOT quiet itself to a one-line offer on the basis of the named tool #### Scenario: Description omits suppression and the prior blanket carve-out @@ -37,7 +37,7 @@ The description SHALL NOT instruct the agent to suppress or quiet itself merely ### Requirement: Skill routes authoring requests through the routing front door -The skill body SHALL route rule-authoring requests through `taskless help route` +The skill body SHALL route rule-authoring requests through `npx @taskless/cli help route` as the authoring front door, rather than fetching `rule create` directly. The skill SHALL add no linter knowledge of its own and SHALL remain a thin router that defers all authoring judgment to the fetched recipes. @@ -45,7 +45,7 @@ defers all authoring judgment to the fetched recipes. #### Scenario: Authoring requests are routed via route - **WHEN** the user asks to author a rule (with or without naming a tool) -- **THEN** the skill body SHALL direct the agent to fetch `taskless help route` +- **THEN** the skill body SHALL direct the agent to fetch `npx @taskless/cli help route` before any login-gated path - **AND** SHALL NOT embed linter-specific rule knowledge in the skill body From 6923eb3ff8b47db3ed1146c650cf29b896a8b60c Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 17:10:55 -0700 Subject: [PATCH 08/28] docs(openspec): Clarify detect output schema is internal, not published Reword the cli-detect spec: detect validates its --json output against an internal Zod schema before printing (same as info/check), rather than a "published output schema." The schema is an internal contract and detect exposes no --schema mode. Resolves the remaining Copilot note on PR #23. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../local-rule-routing/specs/cli-detect/spec.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/openspec/changes/local-rule-routing/specs/cli-detect/spec.md b/openspec/changes/local-rule-routing/specs/cli-detect/spec.md index 135d74bc..ddd68199 100644 --- a/openspec/changes/local-rule-routing/specs/cli-detect/spec.md +++ b/openspec/changes/local-rule-routing/specs/cli-detect/spec.md @@ -59,11 +59,14 @@ authentication. ### Requirement: Detect emits a stable JSON shape When `--json` is set, `detect` SHALL emit a single structured JSON object whose -shape is validated by a published output schema, consistent with other -`--json` commands in the CLI. +shape is validated internally against a stable Zod output schema before being +printed, consistent with how other `--json` commands in the CLI (e.g. `info`, +`check`) validate their output. The schema is an internal contract, not a +published artifact, and `detect` does not expose a `--schema` mode. -#### Scenario: JSON output validates against the schema +#### Scenario: JSON output validates against the internal schema - **WHEN** `detect --json` succeeds -- **THEN** stdout SHALL be a single JSON object conforming to the detect output - schema (linters, languages/frameworks, existing rule styles) +- **THEN** stdout SHALL be a single JSON object that the command has validated + against its internal output schema (linters, languages/frameworks, existing + rule styles) From 95df819df34ae953d0fb2ff53857244b4ab519f2 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 17:12:16 -0700 Subject: [PATCH 09/28] docs(cli): Show detect output shape in the route recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a JSON example of `detect --json` output to the route recipe's scan step, so the agent sees the shape it consumes (linters, languages, frameworks, ruleStyles) — parity with how info/check recipes show their --json output. Addresses review feedback on the routing layer. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/help/route.txt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/help/route.txt b/packages/cli/src/help/route.txt index eb5b0ebf..5ed9ad92 100644 --- a/packages/cli/src/help/route.txt +++ b/packages/cli/src/help/route.txt @@ -23,7 +23,18 @@ failed and the user confirms. ``` This returns the configured linters, languages/frameworks, and the repo's own rule styles. It is deterministic and offline — use it as - ground truth instead of guessing the repo's tooling. + ground truth instead of guessing the repo's tooling. The output shape: + ```json + { + "success": true, + "linters": [{ "name": "eslint", "configFiles": [".eslintrc.json"] }], + "languages": ["JavaScript", "TypeScript"], + "frameworks": ["React"], + "ruleStyles": [ + { "source": ".taskless/rules", "description": "Existing Taskless ast-grep rules." } + ] + } + ``` 2. **Write your reasoning BEFORE naming a destination.** Do not pick a route first and justify it after. Write a short rationale covering: From 525d53d8c59d3f8b2c58b840e5fae8a60fe14310 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 17:31:43 -0700 Subject: [PATCH 10/28] docs(cli): Align route recipe with the three-state contract Tighten route.txt step 3 to match the cli-rule-routing spec: choosing `remote` requires a positive belief the request cannot be solved locally (cross-file/semantic), not mere lack of confidence. Genuine uncertainty routes to a clarifying question, never to a generation-spending remote. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/help/route.txt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/help/route.txt b/packages/cli/src/help/route.txt index 5ed9ad92..28ba6152 100644 --- a/packages/cli/src/help/route.txt +++ b/packages/cli/src/help/route.txt @@ -57,13 +57,15 @@ failed and the user confirms. pattern** → fetch `taskless help static`. Reasonable confidence is enough here; you do not need certainty, because step 4 backstops a wrong-but-reasonable bet. - - **You are NOT reasonably confident it is locally solvable** → fetch - `taskless help remote`. Do not manufacture a deliberate local - failure to reach this; route here directly. + - **You reasonably believe it CANNOT be solved locally** (a positive + judgment — e.g. it needs cross-file or semantic checks ast-grep can't + express) → fetch `taskless help remote`. Do not manufacture a + deliberate local failure to reach this; route here directly. - If you cannot reasonably place the request as local or remote, ASK the - user a clarifying question. Uncertainty is a reason to ask, never a - reason to spend a generation on `remote`. + These are three distinct states. If you genuinely cannot tell whether + the request is local or remote — mere uncertainty, not a belief that it + needs the service — ASK the user a clarifying question. Uncertainty is a + reason to ask, never a reason to spend a generation on `remote`. 4. **Failure fallback (try-verify-escalate).** If you committed to a local `static` rule on reasonable confidence and it then FAILS From 0903037688b704e8f702873e2a9aeb74dd43dd36 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 17:50:11 -0700 Subject: [PATCH 11/28] fix(cli): Harden detect against malformed manifests and false positives Address review on PR #24: - Guard package.json dependency parsing: a malformed field (array/string/ null) no longer yields bogus dependency names via Object.keys. - Match pyproject tool tables exactly: `[tool.ruff]` or a nested `[tool.ruff.lint]`, never a similarly-prefixed sibling like `[tool.ruff-lsp]`. Anchored at line start to avoid value matches. - Rename the linter `configFiles` output field to `evidence`: it holds config paths AND non-path markers (pyproject tables, package deps), so the old name/contract was misleading. - Drop the divergent unused detect errorSchema; the error path emits the standard `{ ok, code, message }` envelope via makeErrorEnvelope. - Run detect tests with telemetry disabled (DO_NOT_TRACK), keeping the offline scan path hermetic, and add coverage for the table-prefix and malformed-manifest cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/commands/detect.ts | 2 +- packages/cli/src/detect/scan.ts | 43 ++++++++++++++++++++++---- packages/cli/src/schemas/detect.ts | 14 ++++----- packages/cli/test/detect.test.ts | 47 +++++++++++++++++++++++++++-- 4 files changed, 89 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/commands/detect.ts b/packages/cli/src/commands/detect.ts index adbe1d5c..82e779e2 100644 --- a/packages/cli/src/commands/detect.ts +++ b/packages/cli/src/commands/detect.ts @@ -59,7 +59,7 @@ export const detectCommand = defineCommand({ } else { console.log("Linters:"); for (const linter of result.linters) { - console.log(` ${linter.name}: ${linter.configFiles.join(", ")}`); + console.log(` ${linter.name}: ${linter.evidence.join(", ")}`); } } diff --git a/packages/cli/src/detect/scan.ts b/packages/cli/src/detect/scan.ts index ea2e9a64..d9006328 100644 --- a/packages/cli/src/detect/scan.ts +++ b/packages/cli/src/detect/scan.ts @@ -4,7 +4,12 @@ import { resolve } from "node:path"; export interface DetectedLinter { name: string; - configFiles: string[]; + /** + * On-disk evidence for this linter: config-file paths, a `pyproject.toml` + * table marker, or a `package.json` dependency marker. Not all entries are + * file paths. + */ + evidence: string[]; } export interface RuleStyle { @@ -163,12 +168,34 @@ interface PackageJson { eslintConfig?: unknown; } +/** Object keys, but only for a plain object (a malformed manifest field that + * is an array, string, or null contributes no dependency names). */ +function plainObjectKeys(value: unknown): string[] { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return []; + } + return Object.keys(value as Record); +} + +/** + * Whether a `pyproject.toml` declares the `[tool.]` table or a nested + * table under it (`[tool.
.]`), without matching a similarly-named + * sibling like `[tool.
-lsp]`. Matches at a line start so a value + * containing the literal text doesn't trigger a false positive. + */ +function hasPyprojectTable(pyproject: string, table: string): boolean { + const escaped = table.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); + return new RegExp(String.raw`^\s*\[tool\.${escaped}(\]|\.)`, "m").test( + pyproject + ); +} + /** Collect all declared dependency names from a parsed package.json. */ function allDependencyNames(packageJson: PackageJson): Set { return new Set([ - ...Object.keys(packageJson.dependencies ?? {}), - ...Object.keys(packageJson.devDependencies ?? {}), - ...Object.keys(packageJson.peerDependencies ?? {}), + ...plainObjectKeys(packageJson.dependencies), + ...plainObjectKeys(packageJson.devDependencies), + ...plainObjectKeys(packageJson.peerDependencies), ]); } @@ -204,8 +231,12 @@ export async function detectRepository(cwd: string): Promise { if (has(file)) evidence.push(file); } for (const table of signal.pyprojectTables ?? []) { - if (pyproject.includes(`[tool.${table}`)) + // Match the exact table `[tool.ruff]` or a nested table + // `[tool.ruff.lint]`, but NOT a similarly-prefixed table like + // `[tool.ruff-lsp]`. The char after the table name must be `]` or `.`. + if (hasPyprojectTable(pyproject, table)) { evidence.push(`pyproject.toml [tool.${table}]`); + } } for (const dep of signal.packageDeps ?? []) { if (deps.has(dep)) evidence.push(`package.json (${dep})`); @@ -215,7 +246,7 @@ export async function detectRepository(cwd: string): Promise { evidence.push("package.json (eslintConfig)"); } if (evidence.length > 0) { - linters.push({ name: signal.name, configFiles: evidence }); + linters.push({ name: signal.name, evidence }); } } diff --git a/packages/cli/src/schemas/detect.ts b/packages/cli/src/schemas/detect.ts index 6817d553..fb54e955 100644 --- a/packages/cli/src/schemas/detect.ts +++ b/packages/cli/src/schemas/detect.ts @@ -3,9 +3,11 @@ import { z } from "zod"; /** A linter detected from configuration on disk */ const detectedLinterSchema = z.object({ name: z.string().describe("Linter identifier, e.g. eslint, ruff, rubocop"), - configFiles: z + evidence: z .array(z.string()) - .describe("Repo-relative config paths that evidenced this linter"), + .describe( + "On-disk evidence: config-file paths, a pyproject table marker, or a package.json dependency marker (not all entries are file paths)" + ), }); /** A surfaced style of the repo's own existing rules */ @@ -35,8 +37,6 @@ export const outputSchema = z.object({ .describe("Styles of the repo's own existing rules"), }); -/** Error schema for `taskless detect --json` on failure */ -export const errorSchema = z.object({ - success: z.literal(false), - error: z.string().describe("Error message"), -}); +// On the (internal-only) error path `detect` emits the standard +// `{ ok: false, code, message }` envelope via makeErrorEnvelope — there is no +// command-specific error schema to keep in sync. diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts index 59b3d7f4..1f572a25 100644 --- a/packages/cli/test/detect.test.ts +++ b/packages/cli/test/detect.test.ts @@ -21,6 +21,14 @@ async function runCli( try { const { stdout, stderr } = await execFileAsync("node", [binPath, ...args], { cwd, + // Keep detect hermetic: telemetry is best-effort and would otherwise + // write an anonymous-id file and attempt network I/O on shutdown, which + // must never be part of detect's offline scan path. + env: { + ...process.env, + DO_NOT_TRACK: "1", + TASKLESS_TELEMETRY_DISABLED: "1", + }, }); return { stdout, stderr, exitCode: 0 }; } catch (error) { @@ -35,7 +43,7 @@ async function runCli( interface DetectJson { success: boolean; - linters: { name: string; configFiles: string[] }[]; + linters: { name: string; evidence: string[] }[]; languages: string[]; frameworks: string[]; ruleStyles: { source: string; description: string }[]; @@ -138,14 +146,47 @@ describe("taskless detect", () => { expect(Object.keys(result).toSorted()).toEqual( ["frameworks", "languages", "linters", "ruleStyles", "success"].toSorted() ); - // A linter entry exposes only name + config evidence, never a rule-name claim. + // A linter entry exposes only name + evidence, never a rule-name claim. for (const linter of result.linters) { expect(Object.keys(linter).toSorted()).toEqual( - ["configFiles", "name"].toSorted() + ["evidence", "name"].toSorted() ); } }); + it("does not false-positive a pyproject table on a similarly-prefixed sibling", async () => { + // `[tool.ruff-lsp]` must NOT be read as the `ruff` tool table. + await writeFile( + join(cwd, "pyproject.toml"), + "[tool.ruff-lsp]\nfoo = 1\n", + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).not.toContain("ruff"); + }); + + it("detects ruff from a nested pyproject table ([tool.ruff.lint])", async () => { + await writeFile( + join(cwd, "pyproject.toml"), + '[tool.ruff.lint]\nselect = ["E"]\n', + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).toContain("ruff"); + }); + + it("ignores a malformed package.json dependency field without crashing", async () => { + // `dependencies` as an array (not an object) must not yield bogus deps. + await writeFile( + join(cwd, "package.json"), + JSON.stringify({ dependencies: ["react"], devDependencies: null }), + "utf8" + ); + const result = await detect(cwd); + expect(result.success).toBe(true); + expect(result.frameworks).toEqual([]); + }); + it("runs successfully with no linters, no network, and no auth", async () => { const result = await detect(cwd); expect(result.success).toBe(true); From 7c32f98f8c43729e0151fc2df4703a9dddababc4 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 18:06:45 -0700 Subject: [PATCH 12/28] docs(cli): Address review on routing recipes PR #25 review: - remote.txt: stop advertising runtime rules / check.ts as current behavior, since runtime-rule support isn't shipped end-to-end yet. Reframe the recipe around gathering inputs and delegating to `rule create`; the service owns rule-type selection and today writes ast-grep rules under .taskless/rules/. - static.txt: instruct writing the matching `id` field in the test file (alongside valid/invalid), as rule create --anonymous and the CLI's test-file writer do and ast-grep test filtering expects. - route.txt: update the detect output example to the renamed `evidence` field (follows the detect rename on PR #24). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/help/remote.txt | 25 +++++++++++++------------ packages/cli/src/help/route.txt | 2 +- packages/cli/src/help/static.txt | 8 +++++--- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/help/remote.txt b/packages/cli/src/help/remote.txt index 0630b43d..823cffc2 100644 --- a/packages/cli/src/help/remote.txt +++ b/packages/cli/src/help/remote.txt @@ -1,12 +1,12 @@ # Topic: remote (CLI v%(CLI_VERSION)s / topic v1) ## Goal -Generate a rule using the Taskless service. The service runs the heavy -classifier and returns either a **static** ast-grep rule or a **runtime** -rule, depending on the request — you do NOT decide static vs runtime -here; that is the service's job. This path consumes a generation and -requires login. Reach it when a rule is not reasonably solvable locally, -or when a believed-local attempt failed and the user confirmed. +Generate a rule using the Taskless service. Your job on this path is to +gather the inputs and hand off to `rule create`; the service generates +the rule and writes the standard rule files. This path consumes a +generation and requires login. Reach it when a rule is not reasonably +solvable locally, or when a believed-local attempt failed and the user +confirmed. ## Preconditions - `.taskless/` directory exists. @@ -31,8 +31,8 @@ or when a believed-local attempt failed and the user confirmed. 3. **Gather the request.** Collect the rule description plus concrete success and failure cases. If you arrived here from `static`, reuse - the cases you already gathered. The service uses these both to - classify (static vs runtime) and to generate. + the cases you already gathered. The service uses these to generate the + rule. 4. **Delegate to the generation backend.** Fetch `taskless help rule create` and follow it. That recipe builds the request payload and @@ -46,10 +46,11 @@ or when a believed-local attempt failed and the user confirmed. ## Important Notes -- Do NOT classify static vs runtime yourself — submit the request and - let the service decide. -- This is the only path that can produce a runtime rule (the service - generates the `check.ts`); the local `static` path cannot. +- Do NOT pre-build the rule yourself on this path — submit the request + and let the service generate it. +- The service owns rule-type selection. Today it generates ast-grep + rules written under `.taskless/rules/`, the same shape the local + `static` path produces. ## See Also diff --git a/packages/cli/src/help/route.txt b/packages/cli/src/help/route.txt index 28ba6152..954c3f8c 100644 --- a/packages/cli/src/help/route.txt +++ b/packages/cli/src/help/route.txt @@ -27,7 +27,7 @@ failed and the user confirms. ```json { "success": true, - "linters": [{ "name": "eslint", "configFiles": [".eslintrc.json"] }], + "linters": [{ "name": "eslint", "evidence": [".eslintrc.json"] }], "languages": ["JavaScript", "TypeScript"], "frameworks": ["React"], "ruleStyles": [ diff --git a/packages/cli/src/help/static.txt b/packages/cli/src/help/static.txt index 50b14511..2332e570 100644 --- a/packages/cli/src/help/static.txt +++ b/packages/cli/src/help/static.txt @@ -28,9 +28,11 @@ writes, so `check`, `improve`, and `verify` treat them identically. `.taskless/rules/.yml` with at minimum `id` (kebab-case), `language`, `severity` (`error`/`warning`/`info`/`hint`), `message`, and the `rule` object. Write tests to - `.taskless/rule-tests/-YYYYMMDD-test.yml` with `valid` and - `invalid` arrays (at least two of each). These paths and shape are the - same ones the service writes — do not invent a different layout. + `.taskless/rule-tests/-YYYYMMDD-test.yml` with the matching `id` + field plus `valid` and `invalid` arrays (at least two of each). The + `id` must match the rule's `id` so ast-grep test filtering pairs them. + These paths and shape are the same ones the service writes — do not + invent a different layout. 4. **Run the verify feedback loop.** Run: ``` From 546f9887f98dbe57142746a77b0a1ce243944712 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 18:31:12 -0700 Subject: [PATCH 13/28] fix(skill): Target @taskless/cli in the changeset; align tool lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #27 review: - The changeset targeted the private, unpublished root `@taskless/skills` package, which produces no release and is overwritten by sync-skill-versions. Target `@taskless/cli` — the published package and the version source of truth that skill metadata.version is locked to. - Add `stylelint` to the skill body's example tool list so it matches the description's list. - Update the tasks.md 4.3 note to describe the real release mechanism (@taskless/cli version drives the skill version via sync-skill-versions). Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/local-rule-routing.md | 2 +- openspec/changes/local-rule-routing/tasks.md | 2 +- skills/taskless/SKILL.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/local-rule-routing.md b/.changeset/local-rule-routing.md index e123ec41..ac842d84 100644 --- a/.changeset/local-rule-routing.md +++ b/.changeset/local-rule-routing.md @@ -1,5 +1,5 @@ --- -"@taskless/skills": minor +"@taskless/cli": minor --- Add a local-first rule-routing layer. A new deterministic `taskless detect` diff --git a/openspec/changes/local-rule-routing/tasks.md b/openspec/changes/local-rule-routing/tasks.md index f2662c3a..143224d2 100644 --- a/openspec/changes/local-rule-routing/tasks.md +++ b/openspec/changes/local-rule-routing/tasks.md @@ -28,7 +28,7 @@ - [x] 4.1 Update `skills/taskless/SKILL.md` `description`: replace the named-tool suppression clause so naming a linter engages routing via `taskless help route`; tighten (reword shorter) rather than append trigger text - [x] 4.1a Measure the resulting `description` length and assert it is ≤ 1024 chars (Agent Skills ceiling); treat overflow as a blocking failure and trim trigger wording until it fits — measured 835 chars - [x] 4.2 Update the skill body to route authoring requests through `taskless help route` (not `rule create` directly); remove the "quiet suggestion" suppression path; keep the skill a thin router with no linter knowledge -- [x] 4.3 Bump the skill `metadata.version` per the file conventions — version is build-locked to package version (assertSkillVersions); added a `minor` changeset so the release path bumps both in lockstep rather than hand-editing a mismatch +- [x] 4.3 Bump the skill `metadata.version` per the file conventions — skill version is build-locked to the `@taskless/cli` package version (assertSkillVersions), which `scripts/sync-skill-versions.ts` treats as the source of truth; added a `minor` changeset targeting `@taskless/cli` (the root `@taskless/skills` package is private/unpublished) so the release path bumps the CLI version and syncs the skill version in lockstep, rather than hand-editing a mismatch - [x] 4.4 Verify the skill change against the updated `skill-taskless` scenarios (routing on named tool, no suppression wording, local-first before login) ## 5. Validation + quality gate diff --git a/skills/taskless/SKILL.md b/skills/taskless/SKILL.md index 08b17c16..2b26955c 100644 --- a/skills/taskless/SKILL.md +++ b/skills/taskless/SKILL.md @@ -35,7 +35,7 @@ each CLI version. ## Authoring a rule: always start at route For any request to add/write/create a rule — whether or not the user names a -tool (eslint, ruff, biome, ast-grep) — fetch `npx @taskless/cli help route` +tool (eslint, ruff, biome, stylelint, ast-grep) — fetch `npx @taskless/cli help route` and follow it. Do NOT fetch `rule create` directly, and do NOT author from your own linter knowledge. `route` runs `detect`, reasons about the request, and decides whether the rule is built in an existing linter (`existing`), as a From 7d9f4ce515840e3c0d05920ed5ccf21c8dafeefc Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 18:44:04 -0700 Subject: [PATCH 14/28] test(cli): Cover every eval trap and fix the fixtures path PR #28 review: - The trap-coverage test asserted the traps were declared but never verified each trap has at least one case, so the dataset could silently stop covering a failure direction (e.g. under-engage) while the test stayed green. Now every declared trap must have >= 1 case, and under-engage cases are asserted to route to `existing`. - Fix the tasks.md note to point at the real fixture/test paths under packages/cli/test/. Co-Authored-By: Claude Opus 4.8 (1M context) --- openspec/changes/local-rule-routing/tasks.md | 2 +- packages/cli/test/route-eval.test.ts | 35 +++++++++++++------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/openspec/changes/local-rule-routing/tasks.md b/openspec/changes/local-rule-routing/tasks.md index 18417a5b..bf03b6e3 100644 --- a/openspec/changes/local-rule-routing/tasks.md +++ b/openspec/changes/local-rule-routing/tasks.md @@ -34,6 +34,6 @@ ## 5. Validation + quality gate - [x] 5.1 Run `pnpm openspec validate local-rule-routing` and resolve any issues -- [x] 5.2 Add/curate the honesty eval fixtures (labeled request → expected route) and assert the `route` heuristic against both failure directions: under-confident (escalating a locally-solvable request to login) and over-confident (claiming local for a request that needs the service). Use the fixtures to calibrate the "confident enough for local" threshold — dataset at `test/fixtures/route-eval.json` with a coverage test; route decision is agent-made (recipe-followed), so the test guards dataset balance across routes + both traps rather than running a code classifier +- [x] 5.2 Add/curate the honesty eval fixtures (labeled request → expected route) and assert the `route` heuristic against both failure directions: under-confident (escalating a locally-solvable request to login) and over-confident (claiming local for a request that needs the service). Use the fixtures to calibrate the "confident enough for local" threshold — dataset at `packages/cli/test/fixtures/route-eval.json` with a coverage test (`packages/cli/test/route-eval.test.ts`); route decision is agent-made (recipe-followed), so the test guards dataset balance across routes + every declared trap rather than running a code classifier - [x] 5.3 Run `pnpm typecheck` and `pnpm lint`; fix all failures - [x] 5.4 Manual smoke: `taskless detect --json`, then `taskless help route`/`existing`/`static`/`remote` resolve and read coherently end-to-end diff --git a/packages/cli/test/route-eval.test.ts b/packages/cli/test/route-eval.test.ts index 50466b87..99f5256d 100644 --- a/packages/cli/test/route-eval.test.ts +++ b/packages/cli/test/route-eval.test.ts @@ -24,6 +24,9 @@ const fixtures = JSON.parse( readFileSync(resolve(import.meta.dirname, "fixtures/route-eval.json"), "utf8") ) as EvalFixtures; +const casesForTrap = (trap: string): EvalCase[] => + fixtures.cases.filter((c) => c.trap === trap); + describe("route honesty eval fixtures", () => { it("declares the three routes and both failure-direction traps", () => { expect(fixtures.routes).toEqual(["existing", "static", "remote"]); @@ -55,18 +58,26 @@ describe("route honesty eval fixtures", () => { } }); - it("guards both failure directions: over-claim and over-escalate", () => { - const overClaim = fixtures.cases.filter((c) => c.trap === "over-claim"); - const overEscalate = fixtures.cases.filter( - (c) => c.trap === "over-escalate" - ); - // Over-claim cases must expect a non-`static` local route (Taskless should - // not have grabbed them); over-escalate cases must expect a local route - // (they should not have gone to remote). - expect(overClaim.length).toBeGreaterThan(0); - expect(overEscalate.length).toBeGreaterThan(0); - for (const c of overClaim) expect(c.expected).toBe("existing"); - for (const c of overEscalate) expect(c.expected).toBe("static"); + it("guards every declared trap with at least one correctly-routed case", () => { + // Each declared trap must have at least one case, so the dataset can't + // silently stop covering a failure direction while the test still passes. + for (const trap of fixtures.traps) { + expect( + casesForTrap(trap).length, + `trap ${trap} needs >= 1 case` + ).toBeGreaterThan(0); + } + + // Over-claim: Taskless should not have grabbed it → expect `existing`. + for (const c of casesForTrap("over-claim")) + expect(c.expected).toBe("existing"); + // Over-escalate: locally solvable → expect `static`, not remote. + for (const c of casesForTrap("over-escalate")) + expect(c.expected).toBe("static"); + // Under-engage: naming a tool must engage routing into that linter, not + // suppress → expect `existing`. + for (const c of casesForTrap("under-engage")) + expect(c.expected).toBe("existing"); }); it("includes genuine remote cases that are not locally solvable", () => { From 1b9ef6558e205b4a10655c67581c46bc91d3f7b9 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 18:51:52 -0700 Subject: [PATCH 15/28] chore(openspec): Archive local-rule-routing and sync specs All 25 tasks are complete, so finalize the change on the tip of the stack: apply the delta specs into the main specs (new cli-detect and cli-rule-routing capabilities; cli-help and skill-taskless updates) and move the change to openspec/changes/archive/2026-06-12-local-rule-routing/. With no unarchived change remaining, the PR OpenSpec Archive Check passes for the final merged state. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../.openspec.yaml | 0 .../2026-06-12-local-rule-routing}/design.md | 0 .../proposal.md | 0 .../specs/cli-detect/spec.md | 0 .../specs/cli-help/spec.md | 0 .../specs/cli-rule-routing/spec.md | 0 .../specs/skill-taskless/spec.md | 0 .../2026-06-12-local-rule-routing}/tasks.md | 0 openspec/specs/cli-detect/spec.md | 78 +++++++ openspec/specs/cli-help/spec.md | 31 +++ openspec/specs/cli-rule-routing/spec.md | 209 ++++++++++++++++++ openspec/specs/skill-taskless/spec.md | 40 +++- 12 files changed, 351 insertions(+), 7 deletions(-) rename openspec/changes/{local-rule-routing => archive/2026-06-12-local-rule-routing}/.openspec.yaml (100%) rename openspec/changes/{local-rule-routing => archive/2026-06-12-local-rule-routing}/design.md (100%) rename openspec/changes/{local-rule-routing => archive/2026-06-12-local-rule-routing}/proposal.md (100%) rename openspec/changes/{local-rule-routing => archive/2026-06-12-local-rule-routing}/specs/cli-detect/spec.md (100%) rename openspec/changes/{local-rule-routing => archive/2026-06-12-local-rule-routing}/specs/cli-help/spec.md (100%) rename openspec/changes/{local-rule-routing => archive/2026-06-12-local-rule-routing}/specs/cli-rule-routing/spec.md (100%) rename openspec/changes/{local-rule-routing => archive/2026-06-12-local-rule-routing}/specs/skill-taskless/spec.md (100%) rename openspec/changes/{local-rule-routing => archive/2026-06-12-local-rule-routing}/tasks.md (100%) create mode 100644 openspec/specs/cli-detect/spec.md create mode 100644 openspec/specs/cli-rule-routing/spec.md diff --git a/openspec/changes/local-rule-routing/.openspec.yaml b/openspec/changes/archive/2026-06-12-local-rule-routing/.openspec.yaml similarity index 100% rename from openspec/changes/local-rule-routing/.openspec.yaml rename to openspec/changes/archive/2026-06-12-local-rule-routing/.openspec.yaml diff --git a/openspec/changes/local-rule-routing/design.md b/openspec/changes/archive/2026-06-12-local-rule-routing/design.md similarity index 100% rename from openspec/changes/local-rule-routing/design.md rename to openspec/changes/archive/2026-06-12-local-rule-routing/design.md diff --git a/openspec/changes/local-rule-routing/proposal.md b/openspec/changes/archive/2026-06-12-local-rule-routing/proposal.md similarity index 100% rename from openspec/changes/local-rule-routing/proposal.md rename to openspec/changes/archive/2026-06-12-local-rule-routing/proposal.md diff --git a/openspec/changes/local-rule-routing/specs/cli-detect/spec.md b/openspec/changes/archive/2026-06-12-local-rule-routing/specs/cli-detect/spec.md similarity index 100% rename from openspec/changes/local-rule-routing/specs/cli-detect/spec.md rename to openspec/changes/archive/2026-06-12-local-rule-routing/specs/cli-detect/spec.md diff --git a/openspec/changes/local-rule-routing/specs/cli-help/spec.md b/openspec/changes/archive/2026-06-12-local-rule-routing/specs/cli-help/spec.md similarity index 100% rename from openspec/changes/local-rule-routing/specs/cli-help/spec.md rename to openspec/changes/archive/2026-06-12-local-rule-routing/specs/cli-help/spec.md diff --git a/openspec/changes/local-rule-routing/specs/cli-rule-routing/spec.md b/openspec/changes/archive/2026-06-12-local-rule-routing/specs/cli-rule-routing/spec.md similarity index 100% rename from openspec/changes/local-rule-routing/specs/cli-rule-routing/spec.md rename to openspec/changes/archive/2026-06-12-local-rule-routing/specs/cli-rule-routing/spec.md diff --git a/openspec/changes/local-rule-routing/specs/skill-taskless/spec.md b/openspec/changes/archive/2026-06-12-local-rule-routing/specs/skill-taskless/spec.md similarity index 100% rename from openspec/changes/local-rule-routing/specs/skill-taskless/spec.md rename to openspec/changes/archive/2026-06-12-local-rule-routing/specs/skill-taskless/spec.md diff --git a/openspec/changes/local-rule-routing/tasks.md b/openspec/changes/archive/2026-06-12-local-rule-routing/tasks.md similarity index 100% rename from openspec/changes/local-rule-routing/tasks.md rename to openspec/changes/archive/2026-06-12-local-rule-routing/tasks.md diff --git a/openspec/specs/cli-detect/spec.md b/openspec/specs/cli-detect/spec.md new file mode 100644 index 00000000..a9b33f3f --- /dev/null +++ b/openspec/specs/cli-detect/spec.md @@ -0,0 +1,78 @@ +# cli-detect Specification + +## Purpose + +TBD - created by archiving change local-rule-routing. Update Purpose after archive. + +## Requirements + +### Requirement: Detect subcommand exists + +The CLI SHALL provide a `taskless detect` subcommand registered in the top-level +command list, with a `--json` flag and the standard `--dir`/`-d` working-directory +flag. + +#### Scenario: Detect is registered + +- **WHEN** `taskless detect --help` is run +- **THEN** the command SHALL be recognized and print its usage +- **AND** the command SHALL accept `--json` and `--dir`/`-d` + +### Requirement: Detect scans deterministic repo signals only + +The `detect` command SHALL emit only deterministic signals derived from files on +disk: configured linters, detected languages/frameworks, and the styles of the +repo's own existing rules. It SHALL NOT perform any LLM inference and SHALL NOT +match the request against any catalog of known packaged linter rules. + +#### Scenario: Linter configs are detected from disk + +- **WHEN** the working directory contains a recognized linter config (for + example `.eslintrc*`, `eslint.config.js`, `ruff.toml`, a `[tool.ruff]` block in + `pyproject.toml`, `.rubocop.yml`, `biome.json`, or `stylelint` config) +- **THEN** `detect --json` SHALL report each configured linter it found + +#### Scenario: Languages and frameworks are reported + +- **WHEN** `detect --json` runs in a repository +- **THEN** the output SHALL include the languages and frameworks inferred from + manifest and source signals present on disk + +#### Scenario: The repo's own rule styles are surfaced + +- **WHEN** the working directory contains existing rule definitions (for example + custom linter rules or `.taskless/rules/`) +- **THEN** `detect --json` SHALL surface a description of those existing rule + styles for downstream authoring + +#### Scenario: No packaged-rule catalog matching + +- **WHEN** `detect --json` runs +- **THEN** the output SHALL NOT claim a request maps to a specific named packaged + rule (such matching is left to the authoring recipe, not the command) + +### Requirement: Detect runs offline with no network or auth + +The `detect` command SHALL complete without network access and without +authentication. + +#### Scenario: Detect works without login or network + +- **WHEN** `detect --json` runs while logged out and offline +- **THEN** it SHALL produce its signal output successfully +- **AND** it SHALL NOT require or prompt for authentication + +### Requirement: Detect emits a stable JSON shape + +When `--json` is set, `detect` SHALL emit a single structured JSON object whose +shape is validated internally against a stable Zod output schema before being +printed, consistent with how other `--json` commands in the CLI (e.g. `info`, +`check`) validate their output. The schema is an internal contract, not a +published artifact, and `detect` does not expose a `--schema` mode. + +#### Scenario: JSON output validates against the internal schema + +- **WHEN** `detect --json` succeeds +- **THEN** stdout SHALL be a single JSON object that the command has validated + against its internal output schema (linters, languages/frameworks, existing + rule styles) diff --git a/openspec/specs/cli-help/spec.md b/openspec/specs/cli-help/spec.md index f9ba03bc..f9fb6190 100644 --- a/openspec/specs/cli-help/spec.md +++ b/openspec/specs/cli-help/spec.md @@ -155,6 +155,37 @@ The help command's existing intent-telemetry requirement SHALL extend naturally - **WHEN** an agent runs `taskless help onboard` - **THEN** PostHog SHALL receive a `help_onboard` event +### Requirement: Routing topics are registered in the help system + +The help system SHALL register the routing recipes `route`, `existing`, `static`, +and `remote` as embedded help topics, retrievable via `taskless help ` and +listed in the help index, consistent with the existing topic embedding and format +requirements. + +#### Scenario: Routing topics resolve + +- **WHEN** `taskless help route`, `taskless help existing`, + `taskless help static`, or `taskless help remote` is run +- **THEN** the corresponding recipe text SHALL be returned +- **AND** an unknown-topic error SHALL NOT be raised for any of the four + +#### Scenario: Routing topics appear in the index + +- **WHEN** `taskless help` (no arguments) is run +- **THEN** the topic index SHALL include the routing topics so an agent can + discover the authoring front door + +### Requirement: Routing topics emit intent telemetry + +Fetching a routing recipe SHALL emit a per-topic intent telemetry event, +consistent with the existing `help_` telemetry convention. + +#### Scenario: Help topic intent is captured for routing recipes + +- **WHEN** the agent fetches `route`, `existing`, `static`, or `remote` +- **THEN** the help command SHALL capture the corresponding `help_` intent + event with the topic name + ## Goal diff --git a/openspec/specs/cli-rule-routing/spec.md b/openspec/specs/cli-rule-routing/spec.md new file mode 100644 index 00000000..27cca600 --- /dev/null +++ b/openspec/specs/cli-rule-routing/spec.md @@ -0,0 +1,209 @@ +# cli-rule-routing Specification + +## Purpose + +TBD - created by archiving change local-rule-routing. Update Purpose after archive. + +## Requirements + +### Requirement: Route is the local authoring classifier + +The CLI SHALL provide a `route` help recipe that instructs the agent to classify +a rule-authoring request into one of three destinations — `existing`, `static`, +or `remote` — using `taskless detect --json` signals plus the user's intent. The +`route` recipe SHALL be biased to stay local: it SHALL prefer `existing` or +`static` and SHALL treat `remote` as the escalation of last resort. + +#### Scenario: Route fetches detection before classifying + +- **WHEN** the agent fetches the `route` recipe to author a rule +- **THEN** the recipe SHALL direct the agent to run `taskless detect --json` and + use its signals as input to the classification + +#### Scenario: Route classifies into one of three destinations + +- **WHEN** the agent follows `route` +- **THEN** it SHALL select exactly one of `existing`, `static`, or `remote` +- **AND** it SHALL fetch the corresponding recipe to perform the authoring + +### Requirement: Route states reasoning before naming a destination + +The `route` recipe SHALL require the agent to write an explicit rationale before +naming a destination. The rationale SHALL cover what the `detect` signals show, +whether an existing linter plausibly already covers the request, whether the +pattern is expressible as a simple static ast-grep rule, and the resulting +confidence that the request is locally solvable. The destination SHALL be emitted +only after this rationale, and SHALL follow from it. + +#### Scenario: Rationale precedes the route decision + +- **WHEN** the agent follows `route` to classify a request +- **THEN** it SHALL produce a written rationale covering the detection signals, + existing-linter coverage, ast-grep expressibility, and local-solvability + confidence +- **AND** it SHALL name the destination (`existing`, `static`, or `remote`) only + after that rationale + +#### Scenario: Route is not named before reasoning + +- **WHEN** the agent has not yet articulated its reasoning +- **THEN** the recipe SHALL NOT permit committing to a destination +- **AND** the destination SHALL be a conclusion of the rationale, not asserted + ahead of it + +### Requirement: Route commits to the believed-correct path on reasonable confidence + +The `route` recipe SHALL determine the destination upfront from `detect` signals +and the user's intent, committing to the path it believes is correct. The bar to +commit to a local path SHALL be **reasonable confidence**, not certainty. Routing +distinguishes three states: reasonable confidence the request IS locally solvable +selects a local path; reasonable belief the request is NOT locally solvable selects +`remote` directly, without first attempting a local rule; genuine inability to +judge either way is uncertainty, which SHALL be resolved by asking the user (see +the clarifying-question scenario) and SHALL NOT by itself select `remote`. The +recipe SHALL NOT use a deliberate local attempt-and-fail with no genuine belief of +success as the mechanism for choosing `remote`. + +#### Scenario: Reasonably-confident-local commits locally without a justification probe + +- **WHEN** `route` is reasonably confident the request fits an existing linter or + a simple static ast-grep pattern +- **THEN** it SHALL select `existing` or `static` and proceed locally +- **AND** it SHALL NOT run a throwaway local attempt whose only purpose is to + justify the choice + +#### Scenario: Believed-not-local routes remote upfront + +- **WHEN** `route` reasonably believes the request cannot be solved locally — a + positive judgment, not mere inability to tell +- **THEN** it SHALL select `remote` directly +- **AND** it SHALL NOT manufacture a deliberate local failure to reach that + decision + +#### Scenario: Uncertainty biases toward asking, not toward login + +- **WHEN** `route` cannot reasonably place a request as local or remote +- **THEN** it SHALL prefer clarifying with the user over defaulting to `remote` +- **AND** uncertainty alone SHALL NOT be treated as a reason to consume a + generation via `remote` + +### Requirement: A believed-local path that fails escalates only after confirmation + +The `route` recipe SHALL treat try-verify-escalate as a legitimate failure +fallback: when it committed to a local path on reasonable confidence and the +authored rule then fails verification against the user's success/failure cases, it +SHALL surface the failure and SHALL obtain explicit user confirmation before +calling the Taskless service. The recipe SHALL NOT silently fall through from a +failed local attempt to a service call. + +#### Scenario: Failed local attempt prompts before spending a generation + +- **WHEN** a `static` rule the agent committed to fails verification +- **THEN** the recipe SHALL inform the user the local rule could not capture the + cases +- **AND** SHALL state that generating via the Taskless service uses a generation + and requires login +- **AND** SHALL call the service only after the user confirms + +#### Scenario: No silent fall-through to the service + +- **WHEN** a believed-local attempt fails +- **THEN** the recipe SHALL NOT invoke `remote` / the service without an explicit + user confirmation step + +### Requirement: Route asks the user when multiple paths fit + +The `route` recipe SHALL present the viable options to the user with their +trade-offs, rather than silently selecting one, whenever more than one destination +genuinely fits a request (most commonly both `existing` and `static`). The +trade-off framing SHALL note that `remote` consumes a generation and requires +login, so it is appropriate when a request cannot be solved locally rather than as +a default. + +#### Scenario: Both local paths viable surfaces a choice + +- **WHEN** the repository has a detected linter that fits AND the pattern is a + clean local static ast-grep rule +- **THEN** `route` SHALL present both `existing` and `static` with their + trade-offs and let the user choose + +#### Scenario: Trade-off framing names the generation cost of remote + +- **WHEN** `route` presents options that include `remote` +- **THEN** it SHALL state that `remote` consumes a generation and requires login +- **AND** SHALL frame `remote` as the path for what cannot be solved locally + +### Requirement: Existing recipe authors in the detected linter's dialect + +The CLI SHALL provide an `existing` help recipe that instructs the agent to +author a rule in a linter already detected in the repository, expressed in that +tool's own dialect. The recipe SHALL direct the agent to source authoring +knowledge first from the repository's own existing rules and only then from the +agent's own web research. The recipe SHALL NOT embed or rely on a Taskless- +maintained catalog of linter rules. + +#### Scenario: Repo-first knowledge sourcing + +- **WHEN** the agent follows `existing` for a detected linter +- **THEN** it SHALL first mine the repository's existing rules of that kind for + house style +- **AND** SHALL fall back to web research (WebFetch/WebSearch) only when the + repository signal is insufficient + +#### Scenario: Existing path is author-only + +- **WHEN** the agent authors a rule via `existing` +- **THEN** the recipe SHALL make clear the user's own toolchain runs the rule and + that `taskless check` does not execute the external linter + +### Requirement: Static recipe authors a verified local ast-grep rule + +The CLI SHALL provide a `static` help recipe that instructs the agent to author a +local ast-grep rule on-device, without calling the Taskless service, and to +verify it against the user's success and failure cases before reporting success. +The recipe SHALL produce the canonical on-disk rule shape and paths used by remote +generation so that `check`, `improve`, and `verify` see a single dialect. + +#### Scenario: Local authoring without the service + +- **WHEN** the agent follows `static` +- **THEN** it SHALL write the rule on-device without requiring login or the + Taskless API + +#### Scenario: Verification gates success + +- **WHEN** the agent authors a static rule +- **THEN** it SHALL verify the rule against the provided success/failure cases + before reporting the rule as complete + +#### Scenario: Canonical output shape + +- **WHEN** the agent writes a static rule to disk +- **THEN** the files, paths, and shape SHALL match those produced by remote + generation + +### Requirement: Remote recipe collects inputs and delegates to the service + +The CLI SHALL provide a `remote` help recipe that instructs the agent to gather +the inputs required to call the Taskless service and to invoke the existing rule +generation backend, which runs the service-side classifier and returns either a +static or a runtime rule. The `remote` recipe SHALL require authentication and +SHALL NOT itself decide static versus runtime. + +#### Scenario: Remote requires authentication + +- **WHEN** the agent follows `remote` while logged out +- **THEN** the recipe SHALL direct the agent to the authentication flow before + submitting the request + +#### Scenario: Static-versus-runtime is decided by the service + +- **WHEN** the agent submits an authored request via `remote` +- **THEN** the recipe SHALL rely on the service to classify static versus runtime +- **AND** SHALL NOT make that determination locally + +#### Scenario: Remote output matches local on-disk shape + +- **WHEN** the service returns a generated rule via `remote` +- **THEN** the written files and paths SHALL match the shape produced by the + local `static` path diff --git a/openspec/specs/skill-taskless/spec.md b/openspec/specs/skill-taskless/spec.md index 09d776de..651c3664 100644 --- a/openspec/specs/skill-taskless/spec.md +++ b/openspec/specs/skill-taskless/spec.md @@ -32,22 +32,27 @@ The consolidated skill's `description` frontmatter field SHALL anchor triggers o 1. An explicit reference to "Taskless" in the user's message, OR 2. A reference to the `.taskless/` directory or files within it (rules, rule-tests, rule-metadata), OR -3. A request to add/write/create a rule where the user has NOT named a specific lint/format/static-analysis tool. The description SHALL include four illustrative example tools whose presence in the user's message suppresses this trigger: eslint, ruff, biome, ast-grep. The wording SHALL make clear the list is illustrative — any named lint/format/static-analysis tool suppresses the trigger. +3. A request to add/write/create a rule for code, including requests that name a specific lint/format/static-analysis tool (for example eslint, ruff, biome, stylelint, ast-grep). Naming such a tool SHALL engage the skill's routing flow rather than suppress it; the skill routes the request toward the appropriate authoring destination via `npx @taskless/cli help route`. -The description SHALL NOT contain a blanket "do NOT trigger on generic linting" instruction; that prior carve-out is replaced by the named-tool suppression in clause 3. +The description SHALL NOT instruct the agent to suppress or quiet itself merely because a lint/format/static-analysis tool is named, and SHALL NOT contain a blanket "do NOT trigger on generic linting" instruction. #### Scenario: Description includes anchored trigger phrases - **WHEN** the skill `description` field is read - **THEN** it SHALL include trigger phrases such as "create/add/write a taskless rule", "improve/fix/iterate on this taskless rule", "run taskless", "taskless login", "add taskless to CI" -- **AND** SHALL include the unspecified-tool clause covering "add/write/create a rule" with no tool named -- **AND** SHALL list at least the four illustrative suppressing tool names: eslint, ruff, biome, ast-grep +- **AND** SHALL include a rule-authoring clause covering "add/write/create a rule" for code -#### Scenario: Description omits the prior blanket carve-out +#### Scenario: Naming a linter engages routing rather than suppressing + +- **WHEN** the user asks to add/write/create a rule and names a specific lint/format/static-analysis tool (for example "write an eslint rule for X") +- **THEN** the skill SHALL trigger and route the request via `npx @taskless/cli help route` +- **AND** SHALL NOT quiet itself to a one-line offer on the basis of the named tool + +#### Scenario: Description omits suppression and the prior blanket carve-out - **WHEN** the skill `description` field is read -- **THEN** it SHALL NOT contain wording instructing the agent to never trigger on generic ESLint/linting requests -- **AND** any suppression wording SHALL be expressed via the named-tool clause +- **THEN** it SHALL NOT contain wording instructing the agent to suppress on a named lint/format/static-analysis tool +- **AND** it SHALL NOT contain wording instructing the agent to never trigger on generic ESLint/linting requests #### Scenario: Description is at most 1024 characters @@ -129,3 +134,24 @@ The consolidated skill's frontmatter SHALL include `metadata.commandName: tskl` - **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` +as the authoring front door, rather than fetching `rule create` directly. The +skill SHALL add no linter knowledge of its own and SHALL remain a thin router that +defers all authoring judgment to the fetched recipes. + +#### Scenario: Authoring requests are routed via route + +- **WHEN** the user asks to author a rule (with or without naming a tool) +- **THEN** the skill body SHALL direct the agent to fetch `npx @taskless/cli help route` + before any login-gated path +- **AND** SHALL NOT embed linter-specific rule knowledge in the skill body + +#### Scenario: Local-first before login + +- **WHEN** the routing recipe has not yet demonstrated that a rule cannot be built + locally +- **THEN** the skill SHALL NOT direct the agent toward a login-gated authoring + path From ceac5cec2cc1020a0c10109d6ef281e2140e1743 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 16:02:42 -0700 Subject: [PATCH 16/28] docs(openspec): Propose local-rule-routing change Add the change contract for a local-first rule-routing layer: a deterministic detect command plus route/existing/static/remote recipes that keep authoring on-device and gate the login wall behind reasonable confidence with a confirm-before-service fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../changes/local-rule-routing/.openspec.yaml | 2 + openspec/changes/local-rule-routing/design.md | 265 ++++++++++++++++++ .../changes/local-rule-routing/proposal.md | 87 ++++++ .../specs/cli-detect/spec.md | 69 +++++ .../local-rule-routing/specs/cli-help/spec.md | 32 +++ .../specs/cli-rule-routing/spec.md | 199 +++++++++++++ .../specs/skill-taskless/spec.md | 57 ++++ 7 files changed, 711 insertions(+) create mode 100644 openspec/changes/local-rule-routing/.openspec.yaml create mode 100644 openspec/changes/local-rule-routing/design.md create mode 100644 openspec/changes/local-rule-routing/proposal.md create mode 100644 openspec/changes/local-rule-routing/specs/cli-detect/spec.md create mode 100644 openspec/changes/local-rule-routing/specs/cli-help/spec.md create mode 100644 openspec/changes/local-rule-routing/specs/cli-rule-routing/spec.md create mode 100644 openspec/changes/local-rule-routing/specs/skill-taskless/spec.md diff --git a/openspec/changes/local-rule-routing/.openspec.yaml b/openspec/changes/local-rule-routing/.openspec.yaml new file mode 100644 index 00000000..e0c0898f --- /dev/null +++ b/openspec/changes/local-rule-routing/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-11 diff --git a/openspec/changes/local-rule-routing/design.md b/openspec/changes/local-rule-routing/design.md new file mode 100644 index 00000000..1a0d8493 --- /dev/null +++ b/openspec/changes/local-rule-routing/design.md @@ -0,0 +1,265 @@ +## Context + +The Taskless skill is a thin router: it holds no recipes and delegates to +`npx @taskless/cli help `, which returns a bundled `.txt` recipe embedded +at build time. Today the only authoring entry is `rule create`, which is +rule-type-agnostic, login-gated, and runs generation off the developer's machine +via the Taskless service. The service-side classifier that decides static vs +runtime (`classifyStep`, Runtime Rules project) is **not reachable locally** — +it is login-only by design, because it carries inference off-machine. + +Three constraints shape this design: + +1. **No local classifyStep.** The local path cannot call the service classifier. + It must make a coarser, cheaper decision on-device. +2. **Login is a wall.** Reaching the service requires auth. Sending a user there + unnecessarily is the failure mode we are designing against. +3. **The skill adds no rule knowledge.** Per the existing router philosophy, all + judgment lives in fetched recipes; the CLI ships deterministic signals and + recipe text, never a maintained catalog of linter rules. + +The check pipeline already models multi-scanner aggregation (`source` field), and +remote rule generation already writes the same on-disk shape and paths as local +generation (canonical shape pinned by the Runtime Rules project). So downstream +tools (`check`, `improve`, `verify`) see a single dialect regardless of origin. + +## Goals / Non-Goals + +**Goals:** + +- A deterministic, offline `taskless detect --json` that reports repo signals + (linters configured, languages/frameworks, the repo's own rule styles). +- A local routing recipe (`route`) that reasons first, then commits to the + believed-correct destination (`existing | static | remote`) on reasonable + confidence, biased to stay local. +- Try-verify-escalate as the _failure fallback_: a path committed to on reasonable + belief that fails verification escalates — and escalation to the service prompts + and confirms with the user before spending a generation. +- Asking the user when multiple paths fit, with trade-offs (including the + generation cost of `remote`). +- Authoring recipes for each destination (`existing`, `static`, `remote`). +- Reverse the skill's named-tool suppression so naming a linter engages routing. + +**Non-Goals:** + +- Defining or computing static-vs-runtime classification locally. That cut is + owned by the Runtime Rules project and lives behind `remote`. +- Maintaining any catalog/knowledge-graph of linter rules or a `howto` API + (explicitly rejected — see Decisions). Linter knowledge is sourced at author + time from the repo and the agent's WebSearch/WebFetch. +- Changing the `rule create` remote backend, the canonical on-disk rule shape, or + `check`/`improve`/`verify` behavior. +- Running external linters inside `taskless check` (the `existing` path is + author-only; the user's own toolchain runs the rule). + +## Decisions + +### D1 — `detect` is a real command; routing is recipes + +`taskless detect --json` is an executable, deterministic command. `route`, +`existing`, `static`, `remote` are `help` recipes the agent follows. + +_Why:_ A CLI cannot classify natural-language intent without an LLM, so the +"what kind of rule is this" judgment must live in a recipe the agent executes. +But "what linters/languages are present" is pure signal and benefits from +determinism — making it a command gives the recipe a stable, testable input and +keeps the agent from hallucinating the repo's tooling. + +_Alternative considered:_ a single `taskless route ""` command that +classifies directly. Rejected — it would need an on-device LLM call or a brittle +heuristic, and it couples a deterministic signal scan to a judgment that belongs +in the agent. + +### D2 — `route` commits to the believed-correct path; escalation is the failure fallback + +`route` determines the destination **upfront** from `detect` signals plus intent, +choosing the path it _believes_ is correct. The bar to commit to a local path is +**reasonable confidence**, not certainty — because the safety net is an honest +fallback, not a perfect prediction. The flow: + +``` +route (reason FIRST, then decide, then act): + 0. write the rationale BEFORE naming a destination: + - what the `detect` signals show (linters, languages, repo rule styles) + - whether an existing linter plausibly already covers this + - whether the pattern is expressible as a simple static ast-grep rule + - the resulting confidence that it is locally solvable + 1. existing AND static both fit? -> ASK the user, explain trade-offs (D8) + 2. existing linter clearly fits? -> existing (no login) + 3. reasonably confident simple static? -> static (author + verify) (no login) + 4. not reasonably confident it's local? -> remote (login) + + FALLBACK (failure state): a path we believed in (static) fails verification + -> PROMPT the user and CONFIRM before calling the service + ("local rule couldn't capture the cases; generate via Taskless? + this uses a generation and needs login") -> on yes -> remote +``` + +The destination is emitted **after** the rationale and must follow from it. The +recipe forbids naming a route before the reasoning is written. + +_Why (reason-before-route):_ Confidence assessments are unreliable when the model +names a destination first and justifies it after — it rationalizes a snap call. +Requiring the rationale to be written _before_ the route is named conditions the +decision on the contextual work (what `detect` shows, whether a packaged rule +exists, whether ast-grep can express the pattern), so the confidence judgment is +earned rather than asserted. The route must be a conclusion of the reasoning. + +_Why (commit on reasonable confidence, not certainty):_ Demanding high confidence +to act locally would push borderline-but-solvable requests to the service +unnecessarily — and the service costs generations (D8). Reasonable confidence is +enough to _commit_ to local because try-verify-escalate backstops a wrong bet: +the `static` recipe authors the rule and verifies it against the user's cases, and +if that believed-correct attempt fails, the recipe escalates. This is the +legitimate role of try-verify-escalate — a **failure handler for a path we +believed in**, not a route-selection probe. + +_Why escalation prompts and confirms:_ The service consumes a generation and +requires login. A local attempt that fails must NOT silently fall through to the +service — the recipe surfaces the failure and asks the user to confirm the +service call. This keeps generations from being spent without consent and makes +the (now justified) login ask explicit. + +_The distinction that matters:_ what is rejected is **fail-first as the selection +mechanism** — deliberately attempting local with no real belief it will work, just +to manufacture a justification for `remote`. What is kept is **try-verify-escalate +as a fallback** — committing to local on reasonable belief, and escalating (with +confirmation) only when that genuine attempt fails. `route` never names "runtime"; +it only judges "am I reasonably confident this is a simple local rule?" and, when +not, routes remote upfront. + +_Alternative considered (rejected):_ escalate to `remote` on _uncertainty alone_. +Rejected — uncertainty must not by itself send users to a generation-consuming, +login-gated path; only a reasoned judgment (confident-local, or a verified local +failure) drives the choice. + +### D3 — `detect` emits pure repo signals only; no rule-pattern detection + +`detect` reports linters present, languages/frameworks, and the repo's own +rule-authoring styles. It does **not** try to match a request against known +packaged rules (e.g. "this is `no-console`"). + +_Why:_ The space of packaged-rule detections is effectively infinite and changes +constantly across ecosystems — a poor determinism target that would drag a +maintained catalog back into the CLI. That judgment is cheap for the LLM in the +`existing` recipe (repo + WebFetch) and expensive to keep correct in code. + +### D4 — Knowledge is sourced at author time, not maintained by Taskless + +The `existing` recipe instructs the agent to mine the repo's own rules of the +relevant kind first, then WebFetch the linter's current docs only if that is +thin. Taskless ships the recipe (where to look), not the knowledge. + +_Why:_ A Taskless-hosted knowledge graph / `howto` endpoint is WebSearch/WebFetch +in disguise with a perpetual scraping-and-staleness maintenance bill. The agent +already has fresh web access; the repo is the highest-signal source for house +style. (There is precedent for `/cli/api/*` endpoints if server-fresh recipes are +ever needed, but bundled-first / API-as-enrichment remains the posture.) + +### D5 — `route` replaces `rule create` as the authoring front door + +The skill and topic index point "author a rule" requests at `route`, not directly +at `rule create`. `rule create` remains the executable backend that the `remote` +recipe invokes; it is unchanged. + +_Why:_ It inserts the local-first decision before any login-gated path, without +disturbing the working remote backend. + +### D6 — Local `static` output matches the canonical shape + +The `static` recipe writes the same on-disk rule shape and paths as remote +generation (the canonical shape pinned upstream). + +_Why:_ `check`, `improve`, and `verify` must see one dialect. Divergent local vs +remote output would fork all three downstream tools. + +### D7 — The skill `description` 1024-char ceiling is a gating check, not an afterthought + +The Agent Skills spec caps the skill `description` at 1024 characters. Reversing +the named-tool suppression and adding routing trigger language both consume that +budget. Every edit to the description SHALL be measured against 1024, and trigger +wording SHALL be tightened (reworded shorter) rather than appended when the budget +is tight. + +_Why:_ The description is the skill's trigger surface; silently overflowing 1024 +breaks loading or truncates triggers. Capturing the limit as a first-class +constraint now — before more capabilities pile trigger phrases onto the field — +prevents a later capability from being blocked by a full description. This is why +the constraint is surfaced in the proposal and carries an explicit length +scenario in the spec, not just a code comment. + +### D8 — When multiple paths fit, ask the user and explain trade-offs + +When more than one destination genuinely fits — most often `existing` and `static` +both viable — `route` SHALL present the options to the user with their trade-offs +rather than silently picking one. The trade-off framing SHALL include that the +service path (`remote`) consumes a generation and requires login, so it is the +right tool when something _cannot_ be solved locally, not a default. + +_Why:_ When two local paths both work, the choice is a user preference (which +toolchain owns the rule), not a technical fact the recipe should decide for them. +Surfacing it builds trust and keeps the user in control of where their rules live. +Naming the generation cost of `remote` makes the local-first bias legible: the +service is valuable precisely when local can't deliver, and spending a generation +should be a conscious choice. + +_Resolves open question:_ "should `route` offer `existing` and `static` in +parallel?" — yes, when both fit or the agent is unsure, ask and explain. + +## Risks / Trade-offs + +- **[`route` is over-confident and commits local when it shouldn't]** → The + `static` recipe verifies the authored rule against the user's success/failure + cases before reporting success, so a wrong-but-reasonable local bet surfaces as a + verified failure, not a silently bad rule. That failure escalates via try-verify- + escalate — the legitimate fallback — and the escalation prompts-and-confirms + before spending a generation, so an over-confident bet costs at most a confirm + dialog, never a silent service charge. +- **[`route` demands too much confidence and pushes solvable requests to the + service]** → The commit bar is _reasonable_ confidence, not certainty (D2), + precisely because the fallback backstops a wrong bet cheaply. Tuned with the + honesty eval fixtures across both failure directions. +- **[Reversing suppression pushes the skill `description` over the 1024-char Agent + Skills ceiling]** → The 1024 limit is a gating check on every description edit + (D7); trigger wording is tightened, not appended, and the spec carries an + explicit length scenario. +- **[`existing` author-only confuses users who expect `taskless check` to run + their linter]** → Recipe text must be explicit that the user's own toolchain + runs the authored rule; Taskless does not aggregate external linters in v1. +- **[Recipe drift vs the Runtime Rules canonical shape]** → D6 ties local output + to the upstream shape; verification (`rule verify`) catches shape regressions. +- **[Skill trigger reversal causes false engagement on tool-named requests]** → + The trigger still anchors on rule-authoring intent; naming a linter routes to + `route`, which can still conclude "this is a one-line config in your existing + tool" without heavy machinery. + +## Migration Plan + +1. Ship `detect` (additive command, no behavior change to existing commands). +2. Add the four recipes and register them in the help index (additive). +3. Update `skill-taskless` trigger/router text to engage `route`. This is the + only behavior change users perceive; it is reversible by restoring the + suppression clause. +4. No data migration; no change to on-disk rule shape or stored state. + +Rollback: revert the skill text and unregister the recipes; `detect` can remain +harmlessly as an unused command. + +## Resolved Questions + +- **Offer `existing` and `static` in parallel when both fit?** Resolved (D8): yes + — when both fit or the agent is unsure, ask the user and explain trade-offs, + including that `remote` consumes a generation and is for what can't be solved + locally. +- **How confident is "confident enough" to commit to `static`?** Resolved (D2): + _reasonably_ confident, not certain — because try-verify-escalate (with a + prompt-and-confirm before the service) backstops a wrong-but-reasonable bet, so + borderline-solvable requests are not pushed to the service unnecessarily. The + honesty eval fixtures calibrate the threshold in recipe language. +- **Where does the `static` candidate live and how is it cleaned up on fallback?** + Resolved: mirror the existing `.taskless/.tmp-*` + guaranteed-cleanup pattern + from `rule create` (cleanup on both success and failure). + +## Open Questions + +- None outstanding. diff --git a/openspec/changes/local-rule-routing/proposal.md b/openspec/changes/local-rule-routing/proposal.md new file mode 100644 index 00000000..f1778581 --- /dev/null +++ b/openspec/changes/local-rule-routing/proposal.md @@ -0,0 +1,87 @@ +## Why + +Authoring a rule today routes straight to `rule create`, which is login-gated and +runs generation off the developer's machine. There is no local front door that +asks "what kind of rule is this, and can I build it on-device first?" — so users +hit a login wall before Taskless has demonstrated it needs the service. At the +same time, the skill actively _suppresses_ itself when a user names a linter +(eslint, ruff, biome), stepping back from exactly the requests where Taskless +could help author the rule in that tool's own dialect. We want a local routing +layer that keeps authoring on-device whenever possible, engages other linters +instead of deferring, and only suggests login when a rule provably cannot be +built locally. + +## What Changes + +- **NEW `taskless detect --json`** — a deterministic, offline repo-signal scan: + which linters are configured, languages/frameworks present, and the styles of + the repo's own existing rules. No LLM, no network. Feeds the routing recipe. +- **NEW routing recipe layer** under `help`, replacing the rule-type-agnostic + `rule create` entry as the front door for "author a rule": + - `route` — the lightweight **local classifier**. Biased to stay local; + suggests login only when local authoring provably fails. + - `existing` — author a rule in a linter already detected in the repo, in that + tool's dialect, sourced from the repo's own rules plus the agent's WebFetch. + - `static` — author a local ast-grep rule on-device, verified against the + user's success/failure cases (the lineage of `rule create --anonymous`). + - `remote` — collect inputs and call the Taskless service, which runs the heavy + classifier and returns either a static or a runtime rule (login required). +- **NEW confidence-gated routing contract** — `route` first writes an explicit + rationale (what `detect` shows, whether an existing linter covers it, whether + ast-grep can express it, resulting local-solvability confidence), and only then + names the destination it believes is correct, as a conclusion of that reasoning. + It commits to a local path on **reasonable** confidence (not certainty); when + that confidence is absent it selects `remote` directly. Try-verify-escalate is + the _failure fallback_: a local path committed to in good faith that fails + verification escalates — but escalation to the generation-consuming, login-gated + service **prompts and confirms with the user first**, never silently. When + multiple paths fit, `route` asks the user and explains the trade-offs. +- **MODIFIED skill trigger posture** — naming a specific linter should _engage_ + the authoring flow via `route`, not quiet the skill to a one-line offer. +- This change does **not** define static-vs-runtime classification. That cut is + owned upstream by the Runtime Rules project and lives behind `remote`; `route` + only decides local-vs-remote. + +## Capabilities + +### New Capabilities + +- `cli-detect`: A deterministic `taskless detect --json` command that scans the + working directory for linter configs, languages/frameworks, and the repo's own + rule-authoring styles, emitting structured signals for downstream routing. No + inference, no network. +- `cli-rule-routing`: The `route` / `existing` / `static` / `remote` recipe layer + and the confidence-gated routing contract that determines the destination + upfront, keeps authoring local when there is confidence it is locally solvable, + and routes to the login-gated service directly when that confidence is absent — + avoiding developer-visible failed local attempts. + +### Modified Capabilities + +- `cli-help`: Register the four routing topics (`route`, `existing`, `static`, + `remote`) in the help index and emit their intent telemetry, consistent with + existing topic registration and embedding requirements. +- `skill-taskless`: Reverse the named-tool suppression clause — when a user names + a linter or asks to author a rule, the skill routes through `taskless help +route` instead of quieting. The skill stays a thin router and adds no rule + knowledge of its own. **Hard constraint:** the skill `description` field has a + 1024-character ceiling (Agent Skills spec). Reversing the suppression clause and + adding routing trigger language compete for that budget, so trigger wording must + be _tightened_ as this and future capabilities land — not appended. Treat the + 1024-char limit as a gating check whenever the description changes. + +## Impact + +- **New command**: `packages/cli/src/commands/detect.ts`, registered in + `packages/cli/src/index.ts`; a `detect` output schema under `schemas/`. +- **New help recipes**: `packages/cli/src/help/{route,existing,static,remote}.txt`, + embedded via the existing `import.meta.glob` build step. +- **Skill body + description**: `skills/taskless/SKILL.md` routing/trigger text. +- **Reused, unchanged**: `rule create` (remote backend for `remote`), `rule +verify` (the local verification gate), the canonical on-disk rule shape — remote + and local paths already write the same files/paths, so `check`/`improve`/`verify` + see one dialect. +- **Upstream dependency**: the Runtime Rules project (TSKL `Runtime Rules`) owns + static-vs-runtime; this change references it and never redefines it. +- **No new network dependencies**; `detect` and the local authoring paths are + offline-capable. Only `remote` requires auth. diff --git a/openspec/changes/local-rule-routing/specs/cli-detect/spec.md b/openspec/changes/local-rule-routing/specs/cli-detect/spec.md new file mode 100644 index 00000000..135d74bc --- /dev/null +++ b/openspec/changes/local-rule-routing/specs/cli-detect/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: Detect subcommand exists + +The CLI SHALL provide a `taskless detect` subcommand registered in the top-level +command list, with a `--json` flag and the standard `--dir`/`-d` working-directory +flag. + +#### Scenario: Detect is registered + +- **WHEN** `taskless detect --help` is run +- **THEN** the command SHALL be recognized and print its usage +- **AND** the command SHALL accept `--json` and `--dir`/`-d` + +### Requirement: Detect scans deterministic repo signals only + +The `detect` command SHALL emit only deterministic signals derived from files on +disk: configured linters, detected languages/frameworks, and the styles of the +repo's own existing rules. It SHALL NOT perform any LLM inference and SHALL NOT +match the request against any catalog of known packaged linter rules. + +#### Scenario: Linter configs are detected from disk + +- **WHEN** the working directory contains a recognized linter config (for + example `.eslintrc*`, `eslint.config.js`, `ruff.toml`, a `[tool.ruff]` block in + `pyproject.toml`, `.rubocop.yml`, `biome.json`, or `stylelint` config) +- **THEN** `detect --json` SHALL report each configured linter it found + +#### Scenario: Languages and frameworks are reported + +- **WHEN** `detect --json` runs in a repository +- **THEN** the output SHALL include the languages and frameworks inferred from + manifest and source signals present on disk + +#### Scenario: The repo's own rule styles are surfaced + +- **WHEN** the working directory contains existing rule definitions (for example + custom linter rules or `.taskless/rules/`) +- **THEN** `detect --json` SHALL surface a description of those existing rule + styles for downstream authoring + +#### Scenario: No packaged-rule catalog matching + +- **WHEN** `detect --json` runs +- **THEN** the output SHALL NOT claim a request maps to a specific named packaged + rule (such matching is left to the authoring recipe, not the command) + +### Requirement: Detect runs offline with no network or auth + +The `detect` command SHALL complete without network access and without +authentication. + +#### Scenario: Detect works without login or network + +- **WHEN** `detect --json` runs while logged out and offline +- **THEN** it SHALL produce its signal output successfully +- **AND** it SHALL NOT require or prompt for authentication + +### Requirement: Detect emits a stable JSON shape + +When `--json` is set, `detect` SHALL emit a single structured JSON object whose +shape is validated by a published output schema, consistent with other +`--json` commands in the CLI. + +#### Scenario: JSON output validates against the schema + +- **WHEN** `detect --json` succeeds +- **THEN** stdout SHALL be a single JSON object conforming to the detect output + schema (linters, languages/frameworks, existing rule styles) diff --git a/openspec/changes/local-rule-routing/specs/cli-help/spec.md b/openspec/changes/local-rule-routing/specs/cli-help/spec.md new file mode 100644 index 00000000..113308b6 --- /dev/null +++ b/openspec/changes/local-rule-routing/specs/cli-help/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: Routing topics are registered in the help system + +The help system SHALL register the routing recipes `route`, `existing`, `static`, +and `remote` as embedded help topics, retrievable via `taskless help ` and +listed in the help index, consistent with the existing topic embedding and format +requirements. + +#### Scenario: Routing topics resolve + +- **WHEN** `taskless help route`, `taskless help existing`, `taskless help +static`, or `taskless help remote` is run +- **THEN** the corresponding recipe text SHALL be returned +- **AND** an unknown-topic error SHALL NOT be raised for any of the four + +#### Scenario: Routing topics appear in the index + +- **WHEN** `taskless help` (no arguments) is run +- **THEN** the topic index SHALL include the routing topics so an agent can + discover the authoring front door + +### Requirement: Routing topics emit intent telemetry + +Fetching a routing recipe SHALL emit a per-topic intent telemetry event, +consistent with the existing `help_` telemetry convention. + +#### Scenario: Help topic intent is captured for routing recipes + +- **WHEN** the agent fetches `route`, `existing`, `static`, or `remote` +- **THEN** the help command SHALL capture the corresponding `help_` intent + event with the topic name diff --git a/openspec/changes/local-rule-routing/specs/cli-rule-routing/spec.md b/openspec/changes/local-rule-routing/specs/cli-rule-routing/spec.md new file mode 100644 index 00000000..72112d74 --- /dev/null +++ b/openspec/changes/local-rule-routing/specs/cli-rule-routing/spec.md @@ -0,0 +1,199 @@ +## ADDED Requirements + +### Requirement: Route is the local authoring classifier + +The CLI SHALL provide a `route` help recipe that instructs the agent to classify +a rule-authoring request into one of three destinations — `existing`, `static`, +or `remote` — using `taskless detect --json` signals plus the user's intent. The +`route` recipe SHALL be biased to stay local: it SHALL prefer `existing` or +`static` and SHALL treat `remote` as the escalation of last resort. + +#### Scenario: Route fetches detection before classifying + +- **WHEN** the agent fetches the `route` recipe to author a rule +- **THEN** the recipe SHALL direct the agent to run `taskless detect --json` and + use its signals as input to the classification + +#### Scenario: Route classifies into one of three destinations + +- **WHEN** the agent follows `route` +- **THEN** it SHALL select exactly one of `existing`, `static`, or `remote` +- **AND** it SHALL fetch the corresponding recipe to perform the authoring + +### Requirement: Route states reasoning before naming a destination + +The `route` recipe SHALL require the agent to write an explicit rationale before +naming a destination. The rationale SHALL cover what the `detect` signals show, +whether an existing linter plausibly already covers the request, whether the +pattern is expressible as a simple static ast-grep rule, and the resulting +confidence that the request is locally solvable. The destination SHALL be emitted +only after this rationale, and SHALL follow from it. + +#### Scenario: Rationale precedes the route decision + +- **WHEN** the agent follows `route` to classify a request +- **THEN** it SHALL produce a written rationale covering the detection signals, + existing-linter coverage, ast-grep expressibility, and local-solvability + confidence +- **AND** it SHALL name the destination (`existing`, `static`, or `remote`) only + after that rationale + +#### Scenario: Route is not named before reasoning + +- **WHEN** the agent has not yet articulated its reasoning +- **THEN** the recipe SHALL NOT permit committing to a destination +- **AND** the destination SHALL be a conclusion of the rationale, not asserted + ahead of it + +### Requirement: Route commits to the believed-correct path on reasonable confidence + +The `route` recipe SHALL determine the destination upfront from `detect` signals +and the user's intent, committing to the path it believes is correct. The bar to +commit to a local path SHALL be **reasonable confidence**, not certainty. When +`route` is not reasonably confident a request is locally solvable, it SHALL select +`remote` directly, without first attempting a local rule. The recipe SHALL NOT use +a deliberate local attempt-and-fail with no genuine belief of success as the +mechanism for choosing `remote`. + +#### Scenario: Reasonably-confident-local commits locally without a justification probe + +- **WHEN** `route` is reasonably confident the request fits an existing linter or + a simple static ast-grep pattern +- **THEN** it SHALL select `existing` or `static` and proceed locally +- **AND** it SHALL NOT run a throwaway local attempt whose only purpose is to + justify the choice + +#### Scenario: Not-reasonably-confident routes remote upfront + +- **WHEN** `route` is not reasonably confident the request is locally solvable +- **THEN** it SHALL select `remote` directly +- **AND** it SHALL NOT manufacture a deliberate local failure to reach that + decision + +#### Scenario: Uncertainty biases toward asking, not toward login + +- **WHEN** `route` cannot reasonably place a request as local or remote +- **THEN** it SHALL prefer clarifying with the user over defaulting to `remote` +- **AND** uncertainty alone SHALL NOT be treated as a reason to consume a + generation via `remote` + +### Requirement: A believed-local path that fails escalates only after confirmation + +The `route` recipe SHALL treat try-verify-escalate as a legitimate failure +fallback: when it committed to a local path on reasonable confidence and the +authored rule then fails verification against the user's success/failure cases, it +SHALL surface the failure and SHALL obtain explicit user confirmation before +calling the Taskless service. The recipe SHALL NOT silently fall through from a +failed local attempt to a service call. + +#### Scenario: Failed local attempt prompts before spending a generation + +- **WHEN** a `static` rule the agent committed to fails verification +- **THEN** the recipe SHALL inform the user the local rule could not capture the + cases +- **AND** SHALL state that generating via the Taskless service uses a generation + and requires login +- **AND** SHALL call the service only after the user confirms + +#### Scenario: No silent fall-through to the service + +- **WHEN** a believed-local attempt fails +- **THEN** the recipe SHALL NOT invoke `remote` / the service without an explicit + user confirmation step + +### Requirement: Route asks the user when multiple paths fit + +The `route` recipe SHALL present the viable options to the user with their +trade-offs, rather than silently selecting one, whenever more than one destination +genuinely fits a request (most commonly both `existing` and `static`). The +trade-off framing SHALL note that `remote` consumes a generation and requires +login, so it is appropriate when a request cannot be solved locally rather than as +a default. + +#### Scenario: Both local paths viable surfaces a choice + +- **WHEN** the repository has a detected linter that fits AND the pattern is a + clean local static ast-grep rule +- **THEN** `route` SHALL present both `existing` and `static` with their + trade-offs and let the user choose + +#### Scenario: Trade-off framing names the generation cost of remote + +- **WHEN** `route` presents options that include `remote` +- **THEN** it SHALL state that `remote` consumes a generation and requires login +- **AND** SHALL frame `remote` as the path for what cannot be solved locally + +### Requirement: Existing recipe authors in the detected linter's dialect + +The CLI SHALL provide an `existing` help recipe that instructs the agent to +author a rule in a linter already detected in the repository, expressed in that +tool's own dialect. The recipe SHALL direct the agent to source authoring +knowledge first from the repository's own existing rules and only then from the +agent's own web research. The recipe SHALL NOT embed or rely on a Taskless- +maintained catalog of linter rules. + +#### Scenario: Repo-first knowledge sourcing + +- **WHEN** the agent follows `existing` for a detected linter +- **THEN** it SHALL first mine the repository's existing rules of that kind for + house style +- **AND** SHALL fall back to web research (WebFetch/WebSearch) only when the + repository signal is insufficient + +#### Scenario: Existing path is author-only + +- **WHEN** the agent authors a rule via `existing` +- **THEN** the recipe SHALL make clear the user's own toolchain runs the rule and + that `taskless check` does not execute the external linter + +### Requirement: Static recipe authors a verified local ast-grep rule + +The CLI SHALL provide a `static` help recipe that instructs the agent to author a +local ast-grep rule on-device, without calling the Taskless service, and to +verify it against the user's success and failure cases before reporting success. +The recipe SHALL produce the canonical on-disk rule shape and paths used by remote +generation so that `check`, `improve`, and `verify` see a single dialect. + +#### Scenario: Local authoring without the service + +- **WHEN** the agent follows `static` +- **THEN** it SHALL write the rule on-device without requiring login or the + Taskless API + +#### Scenario: Verification gates success + +- **WHEN** the agent authors a static rule +- **THEN** it SHALL verify the rule against the provided success/failure cases + before reporting the rule as complete + +#### Scenario: Canonical output shape + +- **WHEN** the agent writes a static rule to disk +- **THEN** the files, paths, and shape SHALL match those produced by remote + generation + +### Requirement: Remote recipe collects inputs and delegates to the service + +The CLI SHALL provide a `remote` help recipe that instructs the agent to gather +the inputs required to call the Taskless service and to invoke the existing rule +generation backend, which runs the service-side classifier and returns either a +static or a runtime rule. The `remote` recipe SHALL require authentication and +SHALL NOT itself decide static versus runtime. + +#### Scenario: Remote requires authentication + +- **WHEN** the agent follows `remote` while logged out +- **THEN** the recipe SHALL direct the agent to the authentication flow before + submitting the request + +#### Scenario: Static-versus-runtime is decided by the service + +- **WHEN** the agent submits an authored request via `remote` +- **THEN** the recipe SHALL rely on the service to classify static versus runtime +- **AND** SHALL NOT make that determination locally + +#### Scenario: Remote output matches local on-disk shape + +- **WHEN** the service returns a generated rule via `remote` +- **THEN** the written files and paths SHALL match the shape produced by the + local `static` path diff --git a/openspec/changes/local-rule-routing/specs/skill-taskless/spec.md b/openspec/changes/local-rule-routing/specs/skill-taskless/spec.md new file mode 100644 index 00000000..67a31669 --- /dev/null +++ b/openspec/changes/local-rule-routing/specs/skill-taskless/spec.md @@ -0,0 +1,57 @@ +## MODIFIED Requirements + +### Requirement: Skill description anchors triggers on Taskless-specific phrases + +The consolidated skill's `description` frontmatter field SHALL anchor triggers on: + +1. An explicit reference to "Taskless" in the user's message, OR +2. A reference to the `.taskless/` directory or files within it (rules, rule-tests, rule-metadata), OR +3. A request to add/write/create a rule for code, including requests that name a specific lint/format/static-analysis tool (for example eslint, ruff, biome, stylelint, ast-grep). Naming such a tool SHALL engage the skill's routing flow rather than suppress it; the skill routes the request toward the appropriate authoring destination via `taskless help route`. + +The description SHALL NOT instruct the agent to suppress or quiet itself merely because a lint/format/static-analysis tool is named, and SHALL NOT contain a blanket "do NOT trigger on generic linting" instruction. + +#### Scenario: Description includes anchored trigger phrases + +- **WHEN** the skill `description` field is read +- **THEN** it SHALL include trigger phrases such as "create/add/write a taskless rule", "improve/fix/iterate on this taskless rule", "run taskless", "taskless login", "add taskless to CI" +- **AND** SHALL include a rule-authoring clause covering "add/write/create a rule" for code + +#### Scenario: Naming a linter engages routing rather than suppressing + +- **WHEN** the user asks to add/write/create a rule and names a specific lint/format/static-analysis tool (for example "write an eslint rule for X") +- **THEN** the skill SHALL trigger and route the request via `taskless help route` +- **AND** SHALL NOT quiet itself to a one-line offer on the basis of the named tool + +#### Scenario: Description omits suppression and the prior blanket carve-out + +- **WHEN** the skill `description` field is read +- **THEN** it SHALL NOT contain wording instructing the agent to suppress on a named lint/format/static-analysis tool +- **AND** it SHALL NOT contain wording instructing the agent to never trigger on generic ESLint/linting requests + +#### Scenario: Description is at most 1024 characters + +- **WHEN** the skill `description` field length is measured +- **THEN** it SHALL be at most 1024 characters (Agent Skills spec limit) + +## ADDED Requirements + +### Requirement: Skill routes authoring requests through the routing front door + +The skill body SHALL route rule-authoring requests through `taskless help route` +as the authoring front door, rather than fetching `rule create` directly. The +skill SHALL add no linter knowledge of its own and SHALL remain a thin router that +defers all authoring judgment to the fetched recipes. + +#### Scenario: Authoring requests are routed via route + +- **WHEN** the user asks to author a rule (with or without naming a tool) +- **THEN** the skill body SHALL direct the agent to fetch `taskless help route` + before any login-gated path +- **AND** SHALL NOT embed linter-specific rule knowledge in the skill body + +#### Scenario: Local-first before login + +- **WHEN** the routing recipe has not yet demonstrated that a rule cannot be built + locally +- **THEN** the skill SHALL NOT direct the agent toward a login-gated authoring + path From 19df0b7c6e227f08fe1bda00d8c28a7f9e6105dd Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 17:04:36 -0700 Subject: [PATCH 17/28] docs(openspec): Address review feedback on local-rule-routing contract Resolve Copilot review on PR #23: - Drop the "provably cannot be built locally" framing in the proposal; it set a stricter bar than the confidence-gated contract. Login is suggested when route is not reasonably confident a rule is locally solvable, or when a confident local attempt fails and the user confirms. - Tighten the cli-rule-routing spec so routing distinguishes three states: confident-local, reasonable-belief-not-local (-> remote), and genuine uncertainty (-> ask). Mere uncertainty no longer reads as a remote trigger. - Reference the canonical `npx @taskless/cli help route` invocation in the skill-taskless spec instead of a bare `taskless` binary name. - Fix Markdown inline-code spans that were split across line breaks. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../changes/local-rule-routing/proposal.md | 23 +++++++++++-------- .../local-rule-routing/specs/cli-help/spec.md | 4 ++-- .../specs/cli-rule-routing/spec.md | 18 +++++++++------ .../specs/skill-taskless/spec.md | 8 +++---- 4 files changed, 30 insertions(+), 23 deletions(-) diff --git a/openspec/changes/local-rule-routing/proposal.md b/openspec/changes/local-rule-routing/proposal.md index f1778581..63e2c2ea 100644 --- a/openspec/changes/local-rule-routing/proposal.md +++ b/openspec/changes/local-rule-routing/proposal.md @@ -8,8 +8,9 @@ same time, the skill actively _suppresses_ itself when a user names a linter (eslint, ruff, biome), stepping back from exactly the requests where Taskless could help author the rule in that tool's own dialect. We want a local routing layer that keeps authoring on-device whenever possible, engages other linters -instead of deferring, and only suggests login when a rule provably cannot be -built locally. +instead of deferring, and suggests login only when it is not reasonably +confident a rule can be built locally — or when a confident local attempt has +failed and the user confirms spending a generation. ## What Changes @@ -19,7 +20,8 @@ built locally. - **NEW routing recipe layer** under `help`, replacing the rule-type-agnostic `rule create` entry as the front door for "author a rule": - `route` — the lightweight **local classifier**. Biased to stay local; - suggests login only when local authoring provably fails. + suggests login when it is not reasonably confident the rule is locally + solvable, or when a confident local attempt fails and the user confirms. - `existing` — author a rule in a linter already detected in the repo, in that tool's dialect, sourced from the repo's own rules plus the agent's WebFetch. - `static` — author a local ast-grep rule on-device, verified against the @@ -62,9 +64,10 @@ built locally. `remote`) in the help index and emit their intent telemetry, consistent with existing topic registration and embedding requirements. - `skill-taskless`: Reverse the named-tool suppression clause — when a user names - a linter or asks to author a rule, the skill routes through `taskless help -route` instead of quieting. The skill stays a thin router and adds no rule - knowledge of its own. **Hard constraint:** the skill `description` field has a + a linter or asks to author a rule, the skill routes through + `npx @taskless/cli help route` instead of quieting. The skill stays a thin + router and adds no rule knowledge of its own. **Hard constraint:** the skill + `description` field has a 1024-character ceiling (Agent Skills spec). Reversing the suppression clause and adding routing trigger language compete for that budget, so trigger wording must be _tightened_ as this and future capabilities land — not appended. Treat the @@ -77,10 +80,10 @@ route` instead of quieting. The skill stays a thin router and adds no rule - **New help recipes**: `packages/cli/src/help/{route,existing,static,remote}.txt`, embedded via the existing `import.meta.glob` build step. - **Skill body + description**: `skills/taskless/SKILL.md` routing/trigger text. -- **Reused, unchanged**: `rule create` (remote backend for `remote`), `rule -verify` (the local verification gate), the canonical on-disk rule shape — remote - and local paths already write the same files/paths, so `check`/`improve`/`verify` - see one dialect. +- **Reused, unchanged**: `rule create` (remote backend for `remote`), + `rule verify` (the local verification gate), the canonical on-disk rule + shape — remote and local paths already write the same files/paths, so + `check`/`improve`/`verify` see one dialect. - **Upstream dependency**: the Runtime Rules project (TSKL `Runtime Rules`) owns static-vs-runtime; this change references it and never redefines it. - **No new network dependencies**; `detect` and the local authoring paths are diff --git a/openspec/changes/local-rule-routing/specs/cli-help/spec.md b/openspec/changes/local-rule-routing/specs/cli-help/spec.md index 113308b6..926c0690 100644 --- a/openspec/changes/local-rule-routing/specs/cli-help/spec.md +++ b/openspec/changes/local-rule-routing/specs/cli-help/spec.md @@ -9,8 +9,8 @@ requirements. #### Scenario: Routing topics resolve -- **WHEN** `taskless help route`, `taskless help existing`, `taskless help -static`, or `taskless help remote` is run +- **WHEN** `taskless help route`, `taskless help existing`, + `taskless help static`, or `taskless help remote` is run - **THEN** the corresponding recipe text SHALL be returned - **AND** an unknown-topic error SHALL NOT be raised for any of the four diff --git a/openspec/changes/local-rule-routing/specs/cli-rule-routing/spec.md b/openspec/changes/local-rule-routing/specs/cli-rule-routing/spec.md index 72112d74..0c6cfce4 100644 --- a/openspec/changes/local-rule-routing/specs/cli-rule-routing/spec.md +++ b/openspec/changes/local-rule-routing/specs/cli-rule-routing/spec.md @@ -49,11 +49,14 @@ only after this rationale, and SHALL follow from it. The `route` recipe SHALL determine the destination upfront from `detect` signals and the user's intent, committing to the path it believes is correct. The bar to -commit to a local path SHALL be **reasonable confidence**, not certainty. When -`route` is not reasonably confident a request is locally solvable, it SHALL select -`remote` directly, without first attempting a local rule. The recipe SHALL NOT use -a deliberate local attempt-and-fail with no genuine belief of success as the -mechanism for choosing `remote`. +commit to a local path SHALL be **reasonable confidence**, not certainty. Routing +distinguishes three states: reasonable confidence the request IS locally solvable +selects a local path; reasonable belief the request is NOT locally solvable selects +`remote` directly, without first attempting a local rule; genuine inability to +judge either way is uncertainty, which SHALL be resolved by asking the user (see +the clarifying-question scenario) and SHALL NOT by itself select `remote`. The +recipe SHALL NOT use a deliberate local attempt-and-fail with no genuine belief of +success as the mechanism for choosing `remote`. #### Scenario: Reasonably-confident-local commits locally without a justification probe @@ -63,9 +66,10 @@ mechanism for choosing `remote`. - **AND** it SHALL NOT run a throwaway local attempt whose only purpose is to justify the choice -#### Scenario: Not-reasonably-confident routes remote upfront +#### Scenario: Believed-not-local routes remote upfront -- **WHEN** `route` is not reasonably confident the request is locally solvable +- **WHEN** `route` reasonably believes the request cannot be solved locally — a + positive judgment, not mere inability to tell - **THEN** it SHALL select `remote` directly - **AND** it SHALL NOT manufacture a deliberate local failure to reach that decision diff --git a/openspec/changes/local-rule-routing/specs/skill-taskless/spec.md b/openspec/changes/local-rule-routing/specs/skill-taskless/spec.md index 67a31669..063ed1fd 100644 --- a/openspec/changes/local-rule-routing/specs/skill-taskless/spec.md +++ b/openspec/changes/local-rule-routing/specs/skill-taskless/spec.md @@ -6,7 +6,7 @@ The consolidated skill's `description` frontmatter field SHALL anchor triggers o 1. An explicit reference to "Taskless" in the user's message, OR 2. A reference to the `.taskless/` directory or files within it (rules, rule-tests, rule-metadata), OR -3. A request to add/write/create a rule for code, including requests that name a specific lint/format/static-analysis tool (for example eslint, ruff, biome, stylelint, ast-grep). Naming such a tool SHALL engage the skill's routing flow rather than suppress it; the skill routes the request toward the appropriate authoring destination via `taskless help route`. +3. A request to add/write/create a rule for code, including requests that name a specific lint/format/static-analysis tool (for example eslint, ruff, biome, stylelint, ast-grep). Naming such a tool SHALL engage the skill's routing flow rather than suppress it; the skill routes the request toward the appropriate authoring destination via `npx @taskless/cli help route`. The description SHALL NOT instruct the agent to suppress or quiet itself merely because a lint/format/static-analysis tool is named, and SHALL NOT contain a blanket "do NOT trigger on generic linting" instruction. @@ -19,7 +19,7 @@ The description SHALL NOT instruct the agent to suppress or quiet itself merely #### Scenario: Naming a linter engages routing rather than suppressing - **WHEN** the user asks to add/write/create a rule and names a specific lint/format/static-analysis tool (for example "write an eslint rule for X") -- **THEN** the skill SHALL trigger and route the request via `taskless help route` +- **THEN** the skill SHALL trigger and route the request via `npx @taskless/cli help route` - **AND** SHALL NOT quiet itself to a one-line offer on the basis of the named tool #### Scenario: Description omits suppression and the prior blanket carve-out @@ -37,7 +37,7 @@ The description SHALL NOT instruct the agent to suppress or quiet itself merely ### Requirement: Skill routes authoring requests through the routing front door -The skill body SHALL route rule-authoring requests through `taskless help route` +The skill body SHALL route rule-authoring requests through `npx @taskless/cli help route` as the authoring front door, rather than fetching `rule create` directly. The skill SHALL add no linter knowledge of its own and SHALL remain a thin router that defers all authoring judgment to the fetched recipes. @@ -45,7 +45,7 @@ defers all authoring judgment to the fetched recipes. #### Scenario: Authoring requests are routed via route - **WHEN** the user asks to author a rule (with or without naming a tool) -- **THEN** the skill body SHALL direct the agent to fetch `taskless help route` +- **THEN** the skill body SHALL direct the agent to fetch `npx @taskless/cli help route` before any login-gated path - **AND** SHALL NOT embed linter-specific rule knowledge in the skill body From 934cfb9a371afbe74104a40192196fb037258288 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 17:10:55 -0700 Subject: [PATCH 18/28] docs(openspec): Clarify detect output schema is internal, not published Reword the cli-detect spec: detect validates its --json output against an internal Zod schema before printing (same as info/check), rather than a "published output schema." The schema is an internal contract and detect exposes no --schema mode. Resolves the remaining Copilot note on PR #23. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../local-rule-routing/specs/cli-detect/spec.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/openspec/changes/local-rule-routing/specs/cli-detect/spec.md b/openspec/changes/local-rule-routing/specs/cli-detect/spec.md index 135d74bc..ddd68199 100644 --- a/openspec/changes/local-rule-routing/specs/cli-detect/spec.md +++ b/openspec/changes/local-rule-routing/specs/cli-detect/spec.md @@ -59,11 +59,14 @@ authentication. ### Requirement: Detect emits a stable JSON shape When `--json` is set, `detect` SHALL emit a single structured JSON object whose -shape is validated by a published output schema, consistent with other -`--json` commands in the CLI. +shape is validated internally against a stable Zod output schema before being +printed, consistent with how other `--json` commands in the CLI (e.g. `info`, +`check`) validate their output. The schema is an internal contract, not a +published artifact, and `detect` does not expose a `--schema` mode. -#### Scenario: JSON output validates against the schema +#### Scenario: JSON output validates against the internal schema - **WHEN** `detect --json` succeeds -- **THEN** stdout SHALL be a single JSON object conforming to the detect output - schema (linters, languages/frameworks, existing rule styles) +- **THEN** stdout SHALL be a single JSON object that the command has validated + against its internal output schema (linters, languages/frameworks, existing + rule styles) From 7bc4b6aeefae20718f1d42e7b07a2e0a51250eae Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 12 Jun 2026 04:16:23 -0700 Subject: [PATCH 19/28] ci(openspec): Skip the archive check on non-tip stacked PRs (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A change is archived exactly once, when all of its work has landed. In a stack of PRs, only the tip carries the archived change; the PRs below it still carry the in-flight change directory by design — so failing the archive check on them is noise. Split the workflow into a stack-position detector and the archive check. The detector marks a PR as the tip when no other OPEN PR targets its head branch as a base; the archive job is gated on that, so non-tip PRs show the check as SKIPPED and only the tip (or a standalone PR) runs it. Also broaden the trigger from base=main to all PRs so the (skipped) check is visible across the whole stack, mirroring how we think about changes in flight vs all changes landed. Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/pr-check-openspec.yml | 44 +++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-check-openspec.yml b/.github/workflows/pr-check-openspec.yml index 40395f37..e1cf7ebe 100644 --- a/.github/workflows/pr-check-openspec.yml +++ b/.github/workflows/pr-check-openspec.yml @@ -1,14 +1,52 @@ name: PR OpenSpec Archive Check +# A change is archived exactly once, when ALL of its work has landed. In a +# stack of PRs, only the tip carries the archived change; the PRs below it still +# carry the in-flight change directory by design. So this check runs only on the +# tip of a stack (or a standalone PR) and is skipped on PRs that still have work +# stacked on top of them. Tip = no other OPEN PR targets this PR's head branch +# as its base. on: pull_request: - branches: [main] permissions: contents: read + pull-requests: read jobs: + stack-position: + name: Detect stack position + runs-on: ubuntu-latest + outputs: + is_tip: ${{ steps.detect.outputs.is_tip }} + steps: + - name: Determine whether this PR is the tip of its stack + id: detect + env: + GH_TOKEN: ${{ github.token }} + HEAD_REF: ${{ github.head_ref }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + # Count OPEN PRs that target this PR's head branch as their base. + # Any such PR means work is still stacked on top → not the tip. + children=$(gh pr list --repo "$REPO" --state open --base "$HEAD_REF" \ + --json number --jq 'length' 2>/dev/null || echo 0) + children=${children:-0} + + if [ "$children" -gt 0 ]; then + echo "is_tip=false" >> "$GITHUB_OUTPUT" + echo "This PR has $children open PR(s) stacked on top — changes still in flight." + echo "The OpenSpec archive check is skipped until this PR is the tip of the stack." + else + echo "is_tip=true" >> "$GITHUB_OUTPUT" + echo "No PRs are stacked on top — this PR is the tip (or standalone); the archive check will run." + fi + check-openspec-archived: + needs: stack-position + if: needs.stack-position.outputs.is_tip == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -28,8 +66,8 @@ jobs: echo "::error::Unarchived OpenSpec change directories found under openspec/changes/:" echo "$UNARCHIVED" | sed 's|^| - |' echo "" - echo "Every change must be moved under openspec/changes/archive/ before merge." - echo "Run the openspec archive workflow (e.g. /openspec-archive-change ) and commit the result." + echo "This PR is the tip of its stack (all work landed), so the change must be archived." + echo "Move it under openspec/changes/archive/ (e.g. /openspec-archive-change ) and commit." exit 1 fi From 9be20005b23812ff32616c496a45812ce794d3ef Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 20:26:11 -0700 Subject: [PATCH 20/28] =?UTF-8?q?feat(cli):=20Make=20detect=20monorepo-awa?= =?UTF-8?q?re;=20languages=20=E2=86=92=20linters;=20drop=20frameworks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the detect scan from a root-only probe into a bounded, monorepo-aware tree walk and restructure how signals are derived, addressing review feedback on detect/scan.ts. - Monorepo-aware discovery: a single fs.glob walk (curated IGNORED_DIRECTORIES + MAX_DIRECTORY_DEPTH cap) finds configs and manifests anywhere in the tree; evidence carries the path it was found at (e.g. packages/api/.eslintrc.json). - languages -> linters: a linter is tagged with the language it serves, so its dependency evidence is read only from that language's manifest (node from package.json, Python from pyproject.toml/requirements.txt) instead of conflating ecosystems. Config-file presence stays unconditional per the spec. - Real parsers: pyproject.toml via smol-toml (graceful on malformed input, dropping only its own signal); brittle table regex removed. - Dropped the frameworks signal — the route recipe never consumed it. - Fold rule-style detection into a named helper; readFileNode import alias. Requires Node 22+ (Node 20 is EOL; fs.glob is the walker). Minor changeset. Co-Authored-By: Claude Opus 4.8 --- .changeset/detect-monorepo-node22.md | 20 + openspec/changes/local-rule-routing/design.md | 25 +- .../changes/local-rule-routing/proposal.md | 6 +- .../specs/cli-detect/spec.md | 38 +- openspec/changes/local-rule-routing/tasks.md | 6 +- packages/cli/package.json | 3 +- packages/cli/src/commands/detect.ts | 5 +- packages/cli/src/detect/scan.ts | 510 +++++++++++++----- packages/cli/src/schemas/detect.ts | 7 +- packages/cli/test/detect.test.ts | 84 ++- pnpm-lock.yaml | 9 + 11 files changed, 555 insertions(+), 158 deletions(-) create mode 100644 .changeset/detect-monorepo-node22.md diff --git a/.changeset/detect-monorepo-node22.md b/.changeset/detect-monorepo-node22.md new file mode 100644 index 00000000..020068d2 --- /dev/null +++ b/.changeset/detect-monorepo-node22.md @@ -0,0 +1,20 @@ +--- +"@taskless/cli": minor +--- + +Require Node.js 22+ and make `taskless detect` monorepo-aware. + +- **Node floor raised to 22+.** Node 20 reached end-of-life, and detect now uses + the built-in `fs.glob` walker (Node 22+). This is a breaking engine change, + which pre-1.0 is a minor bump. +- **`detect` is monorepo-aware.** A single bounded tree walk (curated ignore + list + depth cap) finds linter configs and language manifests anywhere in the + repo, not just the root, so a linter configured in a sub-package is detected + with its path as evidence. +- **languages → linters flow.** A linter's dependency evidence is read only from + its own language's manifest (`package.json` for node, `pyproject.toml` / + `requirements.txt` for Python), parsed with real parsers (`smol-toml`, + `yaml`), instead of conflating ecosystems. A malformed manifest drops only its + own signal. +- **Dropped the `frameworks` field** from `detect` output. The routing recipe + never consumed it; the contract now matches its sole consumer. diff --git a/openspec/changes/local-rule-routing/design.md b/openspec/changes/local-rule-routing/design.md index 1a0d8493..d74d09d3 100644 --- a/openspec/changes/local-rule-routing/design.md +++ b/openspec/changes/local-rule-routing/design.md @@ -28,7 +28,7 @@ tools (`check`, `improve`, `verify`) see a single dialect regardless of origin. **Goals:** - A deterministic, offline `taskless detect --json` that reports repo signals - (linters configured, languages/frameworks, the repo's own rule styles). + (linters configured, languages, the repo's own rule styles). - A local routing recipe (`route`) that reasons first, then commits to the believed-correct destination (`existing | static | remote`) on reasonable confidence, biased to stay local. @@ -135,15 +135,32 @@ failure) drives the choice. ### D3 — `detect` emits pure repo signals only; no rule-pattern detection -`detect` reports linters present, languages/frameworks, and the repo's own -rule-authoring styles. It does **not** try to match a request against known -packaged rules (e.g. "this is `no-console`"). +`detect` reports linters present, languages, and the repo's own rule-authoring +styles. It does **not** try to match a request against known packaged rules +(e.g. "this is `no-console`"). _Why:_ The space of packaged-rule detections is effectively infinite and changes constantly across ecosystems — a poor determinism target that would drag a maintained catalog back into the CLI. That judgment is cheap for the LLM in the `existing` recipe (repo + WebFetch) and expensive to keep correct in code. +_Why no `frameworks` signal:_ An earlier cut also reported detected frameworks +(React, Django, …). It was dropped: the sole consumer is `route`, whose rationale +(D2 step 0) reads linters, languages, and repo rule styles — never frameworks. +Framework presence does not change which authoring destination fits, so emitting +it was unused surface. Dropping it aligns the contract with its consumer. + +_Detection shape (languages → linters):_ languages are inferred first (manifest +and marker files), then each linter is probed. A linter is tagged with the +language(s) it serves, so its dependency evidence is read from that language's +own manifest — a node dependency from `package.json`, a Python dependency from +`pyproject.toml`/`requirements.txt` — instead of conflating ecosystems. Config +files are parsed with real parsers (`smol-toml` for `pyproject.toml`, alongside +the existing `yaml`); a malformed manifest drops only its own derived signal and +never fails the scan, because other files in the repo are independent tells. A +config file present on disk is honored regardless of inferred language, per the +"Linter configs are detected from disk" requirement. + ### D4 — Knowledge is sourced at author time, not maintained by Taskless The `existing` recipe instructs the agent to mine the repo's own rules of the diff --git a/openspec/changes/local-rule-routing/proposal.md b/openspec/changes/local-rule-routing/proposal.md index 63e2c2ea..10e1fd58 100644 --- a/openspec/changes/local-rule-routing/proposal.md +++ b/openspec/changes/local-rule-routing/proposal.md @@ -15,8 +15,8 @@ failed and the user confirms spending a generation. ## What Changes - **NEW `taskless detect --json`** — a deterministic, offline repo-signal scan: - which linters are configured, languages/frameworks present, and the styles of - the repo's own existing rules. No LLM, no network. Feeds the routing recipe. + which linters are configured, languages present, and the styles of the repo's + own existing rules. No LLM, no network. Feeds the routing recipe. - **NEW routing recipe layer** under `help`, replacing the rule-type-agnostic `rule create` entry as the front door for "author a rule": - `route` — the lightweight **local classifier**. Biased to stay local; @@ -49,7 +49,7 @@ failed and the user confirms spending a generation. ### New Capabilities - `cli-detect`: A deterministic `taskless detect --json` command that scans the - working directory for linter configs, languages/frameworks, and the repo's own + working directory for linter configs, languages, and the repo's own rule-authoring styles, emitting structured signals for downstream routing. No inference, no network. - `cli-rule-routing`: The `route` / `existing` / `static` / `remote` recipe layer diff --git a/openspec/changes/local-rule-routing/specs/cli-detect/spec.md b/openspec/changes/local-rule-routing/specs/cli-detect/spec.md index ddd68199..f3e43ec2 100644 --- a/openspec/changes/local-rule-routing/specs/cli-detect/spec.md +++ b/openspec/changes/local-rule-routing/specs/cli-detect/spec.md @@ -15,9 +15,16 @@ flag. ### Requirement: Detect scans deterministic repo signals only The `detect` command SHALL emit only deterministic signals derived from files on -disk: configured linters, detected languages/frameworks, and the styles of the -repo's own existing rules. It SHALL NOT perform any LLM inference and SHALL NOT -match the request against any catalog of known packaged linter rules. +disk: configured linters, detected languages, and the styles of the repo's own +existing rules. It SHALL NOT perform any LLM inference and SHALL NOT match the +request against any catalog of known packaged linter rules. + +Detection follows a languages → linters flow: languages are inferred first, and +a linter's dependency evidence is then read from the manifest of that linter's +own language (a node dependency from `package.json`, a Python dependency from +`pyproject.toml`/`requirements.txt`) rather than conflating ecosystems. A +recognized linter config file on disk is honored regardless of the languages +inferred. #### Scenario: Linter configs are detected from disk @@ -26,11 +33,27 @@ match the request against any catalog of known packaged linter rules. `pyproject.toml`, `.rubocop.yml`, `biome.json`, or `stylelint` config) - **THEN** `detect --json` SHALL report each configured linter it found -#### Scenario: Languages and frameworks are reported +#### Scenario: Languages are reported - **WHEN** `detect --json` runs in a repository -- **THEN** the output SHALL include the languages and frameworks inferred from - manifest and source signals present on disk +- **THEN** the output SHALL include the languages inferred from manifest and + marker files present on disk and from the linters detected + +#### Scenario: A linter dependency is sourced from its own language's manifest + +- **WHEN** a dependency-evidenced linter (for example `ruff`) is named only in a + manifest belonging to a different language (for example `package.json`) +- **THEN** `detect --json` SHALL NOT report that linter from the mismatched + manifest + +#### Scenario: Configs in monorepo sub-packages are detected + +- **WHEN** a linter config or language manifest lives in a sub-package rather + than the repository root (for example `packages/api/.eslintrc.json`) +- **THEN** `detect --json` SHALL detect it and SHALL carry the path it was found + at in the linter's evidence +- **AND** the scan SHALL prune a curated set of ignored directories (for example + `node_modules`, `.git`, build output) and SHALL bound traversal depth #### Scenario: The repo's own rule styles are surfaced @@ -68,5 +91,4 @@ published artifact, and `detect` does not expose a `--schema` mode. - **WHEN** `detect --json` succeeds - **THEN** stdout SHALL be a single JSON object that the command has validated - against its internal output schema (linters, languages/frameworks, existing - rule styles) + against its internal output schema (linters, languages, existing rule styles) diff --git a/openspec/changes/local-rule-routing/tasks.md b/openspec/changes/local-rule-routing/tasks.md index db5cb431..ecc20967 100644 --- a/openspec/changes/local-rule-routing/tasks.md +++ b/openspec/changes/local-rule-routing/tasks.md @@ -1,10 +1,10 @@ ## 1. Detect command (cli-detect) -- [x] 1.1 Add a `detect` output schema under `packages/cli/src/schemas/` (linters, languages/frameworks, existing rule styles) -- [x] 1.2 Implement `packages/cli/src/commands/detect.ts`: deterministic, offline scan of linter configs, languages/frameworks, and the repo's own rule styles — no LLM, no network, no auth +- [x] 1.1 Add a `detect` output schema under `packages/cli/src/schemas/` (linters, languages, existing rule styles) +- [x] 1.2 Implement `packages/cli/src/commands/detect.ts`: deterministic, offline scan of linter configs, languages, and the repo's own rule styles — no LLM, no network, no auth; languages → linters flow with dependency evidence read from each linter's own manifest (`smol-toml` for `pyproject.toml`) - [x] 1.3 Register `detect` in `packages/cli/src/index.ts` subCommands with `--json` and `--dir`/`-d` - [x] 1.4 Emit `cli_detect` telemetry consistent with other commands -- [x] 1.5 Add unit tests covering: eslint/ruff/rubocop/biome/stylelint config detection, language/framework inference, repo-rule-style surfacing, and JSON-shape validation against the schema +- [x] 1.5 Add unit tests covering: eslint/ruff/rubocop/biome/stylelint config detection, language inference, per-language dependency sourcing, graceful malformed-manifest handling, repo-rule-style surfacing, and JSON-shape validation against the schema - [x] 1.6 Add a test asserting `detect` produces no packaged-rule-catalog claims and runs without network/auth ## 2. Routing recipes (cli-rule-routing) diff --git a/packages/cli/package.json b/packages/cli/package.json index b0fdddd4..20e80f4d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,7 +25,7 @@ "dist" ], "engines": { - "node": "^20.20.0 || >=22.22.0" + "node": ">=22.22.0" }, "dependencies": { "@ast-grep/cli": "^0.41.0", @@ -36,6 +36,7 @@ "openapi-fetch": "^0.17.0", "picocolors": "^1.1.1", "posthog-node": "^5.28.11", + "smol-toml": "^1.6.1", "sprintf-js": "^1.1.3", "yaml": "^2.8.2", "zod": "^4.3.6" diff --git a/packages/cli/src/commands/detect.ts b/packages/cli/src/commands/detect.ts index 82e779e2..c32756fe 100644 --- a/packages/cli/src/commands/detect.ts +++ b/packages/cli/src/commands/detect.ts @@ -11,7 +11,7 @@ export const detectCommand = defineCommand({ meta: { name: "detect", description: - "Scan the repo for configured linters, languages/frameworks, and existing rule styles (offline, deterministic)", + "Scan the repo for configured linters, languages, and existing rule styles (offline, deterministic)", }, args: { dir: { @@ -66,9 +66,6 @@ export const detectCommand = defineCommand({ console.log( `\nLanguages: ${result.languages.length > 0 ? result.languages.join(", ") : "none detected"}` ); - console.log( - `Frameworks: ${result.frameworks.length > 0 ? result.frameworks.join(", ") : "none detected"}` - ); if (result.ruleStyles.length > 0) { console.log("\nExisting rule styles:"); diff --git a/packages/cli/src/detect/scan.ts b/packages/cli/src/detect/scan.ts index d9006328..2d1d71f9 100644 --- a/packages/cli/src/detect/scan.ts +++ b/packages/cli/src/detect/scan.ts @@ -1,13 +1,17 @@ -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; +import { existsSync, globSync } from "node:fs"; +import { readFile as readFileNode } from "node:fs/promises"; import { resolve } from "node:path"; +import { parse as parseToml } from "smol-toml"; + export interface DetectedLinter { name: string; /** - * On-disk evidence for this linter: config-file paths, a `pyproject.toml` - * table marker, or a `package.json` dependency marker. Not all entries are - * file paths. + * On-disk evidence for this linter: a config-file path, a `pyproject.toml` + * table marker, or a dependency marker from the language's package file. Each + * entry carries the path it was found at, so monorepo evidence + * (`packages/api/.eslintrc.json`) is attributable. Not all entries are file + * paths. */ evidence: string[]; } @@ -20,30 +24,79 @@ export interface RuleStyle { export interface DetectResult { linters: DetectedLinter[]; languages: string[]; - frameworks: string[]; ruleStyles: RuleStyle[]; } /** - * A linter is evidenced by any of: - * - a fixed config filename present on disk - * - a `[tool.]` table in pyproject.toml - * - a dependency name in package.json (dev/prod/peer) + * Directory names pruned from the repo walk. Held as a curated list so the scan + * never descends into dependency trees, build output, or VCS metadata — the + * places a real linter config never lives and where traversal cost explodes. + * Recursive `fs.glob` does not honor `.gitignore`, so we prune explicitly; an + * explicit list is also more deterministic than whatever each repo ignores. + */ +const IGNORED_DIRECTORIES: ReadonlySet = new Set([ + "node_modules", + ".git", + ".hg", + ".svn", + "dist", + "build", + "out", + "coverage", + "vendor", + "target", + ".next", + ".nuxt", + ".svelte-kit", + ".turbo", + ".cache", + ".parcel-cache", + ".venv", + "venv", + "__pycache__", + ".mypy_cache", + ".pytest_cache", + ".tox", + ".gradle", +]); + +/** + * Maximum directory depth (levels below the scan root) the walk descends. + * Bounds traversal on pathological trees; monorepo manifests live well within + * this — root → workspace group → package → nested package is four. + */ +const MAX_DIRECTORY_DEPTH = 8; + +/** + * A linter is curated, deterministic signal — never inference. detect never + * matches a request against a catalog of packaged rules; that judgment lives in + * the `existing` recipe. * - * The list is curated to well-known tools. New entries are deterministic - * signal additions, not inference — detect never matches a request against a - * catalog of packaged rules; that judgment lives in the `existing` recipe. + * Each linter is tagged with the language(s) it serves so dependency evidence + * is read from the right package file: a node dependency lives in + * `package.json`, a Python dependency in `pyproject.toml`/`requirements.txt`. + * Tagging by language lets the scan look for a linter's dependency only in its + * own ecosystem's manifest instead of conflating the two. Config-file presence + * is honored unconditionally, per the detect spec ("a recognized linter config + * ... SHALL report each configured linter it found"), so a lone `.eslintrc.json` + * still detects eslint. */ interface LinterSignal { name: string; + /** Languages this linter serves; a detected linter contributes these. */ + languages: string[]; + /** Fixed config filenames; presence on disk is direct evidence. */ configFiles?: string[]; + /** `[tool.]` tables in `pyproject.toml` (Python linters). */ pyprojectTables?: string[]; - packageDeps?: string[]; + /** Dependency names, matched against the manifest of this linter's language. */ + deps?: string[]; } const LINTER_SIGNALS: readonly LinterSignal[] = [ { name: "eslint", + languages: ["JavaScript", "TypeScript"], configFiles: [ ".eslintrc", ".eslintrc.js", @@ -57,15 +110,17 @@ const LINTER_SIGNALS: readonly LinterSignal[] = [ "eslint.config.cjs", "eslint.config.ts", ], - packageDeps: ["eslint"], + deps: ["eslint"], }, { name: "biome", + languages: ["JavaScript", "TypeScript"], configFiles: ["biome.json", "biome.jsonc"], - packageDeps: ["@biomejs/biome"], + deps: ["@biomejs/biome"], }, { name: "stylelint", + languages: ["JavaScript", "TypeScript"], configFiles: [ ".stylelintrc", ".stylelintrc.js", @@ -77,10 +132,11 @@ const LINTER_SIGNALS: readonly LinterSignal[] = [ "stylelint.config.cjs", "stylelint.config.mjs", ], - packageDeps: ["stylelint"], + deps: ["stylelint"], }, { name: "prettier", + languages: ["JavaScript", "TypeScript"], configFiles: [ ".prettierrc", ".prettierrc.js", @@ -92,27 +148,53 @@ const LINTER_SIGNALS: readonly LinterSignal[] = [ "prettier.config.cjs", "prettier.config.mjs", ], - packageDeps: ["prettier"], + deps: ["prettier"], }, { name: "ruff", + languages: ["Python"], configFiles: ["ruff.toml", ".ruff.toml"], pyprojectTables: ["ruff"], + deps: ["ruff"], + }, + { + name: "flake8", + languages: ["Python"], + configFiles: [".flake8"], + deps: ["flake8"], }, - { name: "flake8", configFiles: [".flake8"] }, { name: "pylint", + languages: ["Python"], configFiles: [".pylintrc", "pylintrc"], pyprojectTables: ["pylint"], + deps: ["pylint"], + }, + { + name: "black", + languages: ["Python"], + pyprojectTables: ["black"], + deps: ["black"], + }, + { + name: "rubocop", + languages: ["Ruby"], + configFiles: [".rubocop.yml", ".rubocop.yaml"], + }, + { name: "clang-tidy", languages: ["C", "C++"], configFiles: [".clang-tidy"] }, + { + name: "swiftlint", + languages: ["Swift"], + configFiles: [".swiftlint.yml", ".swiftlint.yaml"], }, - { name: "black", pyprojectTables: ["black"] }, - { name: "rubocop", configFiles: [".rubocop.yml", ".rubocop.yaml"] }, - { name: "clang-tidy", configFiles: [".clang-tidy"] }, - { name: "swiftlint", configFiles: [".swiftlint.yml", ".swiftlint.yaml"] }, - { name: "checkstyle", configFiles: ["checkstyle.xml"] }, + { name: "checkstyle", languages: ["Java"], configFiles: ["checkstyle.xml"] }, ]; -/** Languages inferred from the presence of a manifest or marker file. */ +/** + * Languages inferred from the presence of a manifest or marker file anywhere in + * the tree. JavaScript and TypeScript are resolved separately (they share + * `package.json`). + */ const LANGUAGE_MARKERS: ReadonlyArray<{ language: string; files: string[] }> = [ { language: "Python", @@ -132,30 +214,19 @@ const LANGUAGE_MARKERS: ReadonlyArray<{ language: string; files: string[] }> = [ { language: "Swift", files: ["Package.swift"] }, ]; -/** Frameworks inferred from a package.json dependency name. */ -const JS_FRAMEWORK_DEPS: ReadonlyArray<{ framework: string; dep: string }> = [ - { framework: "Next.js", dep: "next" }, - { framework: "React", dep: "react" }, - { framework: "Vue", dep: "vue" }, - { framework: "Nuxt", dep: "nuxt" }, - { framework: "Svelte", dep: "svelte" }, - { framework: "Angular", dep: "@angular/core" }, - { framework: "Express", dep: "express" }, - { framework: "Fastify", dep: "fastify" }, - { framework: "NestJS", dep: "@nestjs/core" }, +/** Every basename the walk needs to find, deduped for a single glob pass. */ +const DISCOVERABLE_FILES: readonly string[] = [ + ...new Set([ + "package.json", + "tsconfig.json", + ...LANGUAGE_MARKERS.flatMap((marker) => marker.files), + ...LINTER_SIGNALS.flatMap((signal) => signal.configFiles ?? []), + ]), ]; -/** Frameworks inferred from a Python dependency token. */ -const PY_FRAMEWORK_TOKENS: ReadonlyArray<{ framework: string; token: string }> = - [ - { framework: "Django", token: "django" }, - { framework: "Flask", token: "flask" }, - { framework: "FastAPI", token: "fastapi" }, - ]; - async function readFileSafe(path: string): Promise { try { - return await readFile(path, "utf8"); + return await readFileNode(path, "utf8"); } catch { return undefined; } @@ -177,21 +248,8 @@ function plainObjectKeys(value: unknown): string[] { return Object.keys(value as Record); } -/** - * Whether a `pyproject.toml` declares the `[tool.
]` table or a nested - * table under it (`[tool.
.]`), without matching a similarly-named - * sibling like `[tool.
-lsp]`. Matches at a line start so a value - * containing the literal text doesn't trigger a false positive. - */ -function hasPyprojectTable(pyproject: string, table: string): boolean { - const escaped = table.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); - return new RegExp(String.raw`^\s*\[tool\.${escaped}(\]|\.)`, "m").test( - pyproject - ); -} - /** Collect all declared dependency names from a parsed package.json. */ -function allDependencyNames(packageJson: PackageJson): Set { +function nodeDependencyNames(packageJson: PackageJson): Set { return new Set([ ...plainObjectKeys(packageJson.dependencies), ...plainObjectKeys(packageJson.devDependencies), @@ -200,80 +258,137 @@ function allDependencyNames(packageJson: PackageJson): Set { } /** - * Deterministically scan `cwd` for linter configs, languages/frameworks, and - * the repo's own rule styles. Pure filesystem reads — no network, no auth, no - * LLM. Unreadable or malformed files are skipped rather than failing the scan. + * Parse `pyproject.toml` with a real TOML parser. A malformed file yields + * `undefined` rather than throwing — detect degrades gracefully, losing only + * the pyproject-derived signals (the file's mere presence still marks Python, + * and config files like `ruff.toml` are independent tells). */ -export async function detectRepository(cwd: string): Promise { - const root = resolve(cwd); - const has = (name: string): boolean => existsSync(resolve(root, name)); +function parsePyproject( + raw: string | undefined +): Record | undefined { + if (raw === undefined) return undefined; + try { + return parseToml(raw) as Record; + } catch { + return undefined; + } +} - const packageRaw = await readFileSafe(resolve(root, "package.json")); - let packageJson: PackageJson | undefined; - if (packageRaw) { - try { - packageJson = JSON.parse(packageRaw) as PackageJson; - } catch { - packageJson = undefined; - } +function asRecord(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; } - const deps = packageJson - ? allDependencyNames(packageJson) - : new Set(); + return value as Record; +} - const pyproject = (await readFileSafe(resolve(root, "pyproject.toml"))) ?? ""; +/** + * Whether a parsed `pyproject.toml` declares a `[tool.
]` table (or a + * nested table under it, e.g. `[tool.ruff.lint]`). A real parser makes this + * exact: a similarly-named sibling like `[tool.ruff-lsp]` is a distinct key and + * does not match. + */ +function pyprojectHasTable( + pyproject: Record | undefined, + table: string +): boolean { + const tool = asRecord(pyproject?.tool); + return tool !== undefined && Object.hasOwn(tool, table); +} - // Linters - const linters: DetectedLinter[] = []; - for (const signal of LINTER_SIGNALS) { - const evidence: string[] = []; - for (const file of signal.configFiles ?? []) { - if (has(file)) evidence.push(file); - } - for (const table of signal.pyprojectTables ?? []) { - // Match the exact table `[tool.ruff]` or a nested table - // `[tool.ruff.lint]`, but NOT a similarly-prefixed table like - // `[tool.ruff-lsp]`. The char after the table name must be `]` or `.`. - if (hasPyprojectTable(pyproject, table)) { - evidence.push(`pyproject.toml [tool.${table}]`); - } - } - for (const dep of signal.packageDeps ?? []) { - if (deps.has(dep)) evidence.push(`package.json (${dep})`); - } - // eslintConfig key in package.json is an additional eslint signal - if (signal.name === "eslint" && packageJson?.eslintConfig !== undefined) { - evidence.push("package.json (eslintConfig)"); +/** Strip a PEP 508 requirement string down to its package name. */ +function requirementName(requirement: string): string { + return requirement + .trim() + .split(/[\s<>=!~;[\],()]/)[0]! + .toLowerCase(); +} + +/** Python dependency names declared in a parsed `pyproject.toml` (PEP 621 + Poetry). */ +function pythonDepsFromPyproject( + pyproject: Record | undefined +): string[] { + if (pyproject === undefined) return []; + const names: string[] = []; + + const project = asRecord(pyproject.project); + const projectDeps = project?.dependencies; + if (Array.isArray(projectDeps)) { + for (const entry of projectDeps) { + if (typeof entry === "string") names.push(requirementName(entry)); } - if (evidence.length > 0) { - linters.push({ name: signal.name, evidence }); + } + const optional = asRecord(project?.["optional-dependencies"]); + for (const group of Object.values(optional ?? {})) { + if (!Array.isArray(group)) continue; + for (const entry of group) { + if (typeof entry === "string") names.push(requirementName(entry)); } } - // Languages - const languages: string[] = []; - if (packageJson || has("tsconfig.json")) languages.push("JavaScript"); - if (has("tsconfig.json") || deps.has("typescript")) - languages.push("TypeScript"); - for (const marker of LANGUAGE_MARKERS) { - if (marker.files.some((f) => has(f))) languages.push(marker.language); + const poetry = asRecord(asRecord(pyproject.tool)?.poetry); + for (const key of ["dependencies", "dev-dependencies", "group"]) { + const table = asRecord(poetry?.[key]); + if (table) { + names.push(...Object.keys(table).map((name) => name.toLowerCase())); + } } - // Frameworks - const frameworks: string[] = []; - for (const { framework, dep } of JS_FRAMEWORK_DEPS) { - if (deps.has(dep)) frameworks.push(framework); - } - const pyText = ( - pyproject + - "\n" + - ((await readFileSafe(resolve(root, "requirements.txt"))) ?? "") - ).toLowerCase(); - for (const { framework, token } of PY_FRAMEWORK_TOKENS) { - if (pyText.includes(token)) frameworks.push(framework); + return names; +} + +/** Python dependency names declared in a `requirements.txt`. */ +function pythonDepsFromRequirements(raw: string | undefined): string[] { + if (raw === undefined) return []; + const names: string[] = []; + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("-")) { + continue; + } + names.push(requirementName(trimmed)); } + return names; +} + +/** The basename (last path segment) of a `/`-or-`\`-separated relative path. */ +function basenameOf(relativePath: string): string { + return relativePath.split(/[/\\]/).at(-1) ?? ""; +} + +/** + * Prune the walk: skip the curated ignore directories and anything past the + * depth cap. `fs.glob` calls this on each candidate as it descends, so a `true` + * here stops traversal into that directory. + */ +function shouldExclude(relativePath: string): boolean { + const segments = relativePath.split(/[/\\]/); + if (segments.length > MAX_DIRECTORY_DEPTH) return true; + return IGNORED_DIRECTORIES.has(segments.at(-1) ?? ""); +} - // Rule styles +interface NodeManifest { + path: string; + deps: Set; + hasEslintConfigKey: boolean; +} + +interface PythonManifest { + path: string; + parsed?: Record; + deps: Set; +} + +/** + * Surface the styles of the repo's own existing rules so the authoring recipe + * can match house conventions. `.taskless/rules` is the repo-root, polyglot + * Taskless convention, so it is read at the scan root; the custom-ESLint-rule + * tells (house rule directories and the local-rules plugin dependency) describe + * how this repo already authors lint rules. + */ +function detectRuleStyles( + root: string, + nodeManifests: NodeManifest[] +): RuleStyle[] { const ruleStyles: RuleStyle[] = []; if (existsSync(resolve(root, ".taskless", "rules"))) { ruleStyles.push({ @@ -295,13 +410,162 @@ export async function detectRepository(cwd: string): Promise { }); } } - if (deps.has("eslint-plugin-local") || deps.has("eslint-local-rules")) { + const localRulesManifest = nodeManifests.find( + (manifest) => + manifest.deps.has("eslint-plugin-local") || + manifest.deps.has("eslint-local-rules") + ); + if (localRulesManifest) { ruleStyles.push({ - source: "package.json", + source: localRulesManifest.path, description: "Local ESLint rule plugin in use — author new rules to match it.", }); } + return ruleStyles; +} + +/** + * Deterministically scan `cwd` for the languages present, the linters + * configured for those languages, and the repo's own rule styles. Pure + * filesystem reads — no network, no auth, no LLM. Unreadable or malformed files + * are skipped rather than failing the scan. + * + * The scan is monorepo-aware: a single bounded `fs.glob` walk (curated ignore + * list + depth cap) finds manifests and configs anywhere in the tree, so a + * linter configured in a sub-package is detected with its path as evidence. The + * flow is languages → linters: a linter's dependency is looked up only in its + * own language's manifests. + */ +export async function detectRepository(cwd: string): Promise { + const root = resolve(cwd); + + const foundPaths = globSync(`**/{${DISCOVERABLE_FILES.join(",")}}`, { + cwd: root, + exclude: shouldExclude, + }); + + const pathsByBasename = new Map(); + for (const relativePath of foundPaths) { + const basename = basenameOf(relativePath); + const list = pathsByBasename.get(basename); + if (list) list.push(relativePath); + else pathsByBasename.set(basename, [relativePath]); + } + const pathsFor = (basename: string): string[] => + pathsByBasename.get(basename) ?? []; + + // Node manifests → JS/TS dependency names, per location. + const nodeManifests: NodeManifest[] = []; + for (const relativePath of pathsFor("package.json")) { + const raw = await readFileSafe(resolve(root, relativePath)); + if (raw === undefined) continue; + let parsed: PackageJson; + try { + parsed = JSON.parse(raw) as PackageJson; + } catch { + continue; + } + nodeManifests.push({ + path: relativePath, + deps: nodeDependencyNames(parsed), + hasEslintConfigKey: parsed.eslintConfig !== undefined, + }); + } + + // Python manifests → Python dependency names, per location. + const pythonManifests: PythonManifest[] = []; + for (const relativePath of pathsFor("pyproject.toml")) { + const parsed = parsePyproject( + await readFileSafe(resolve(root, relativePath)) + ); + pythonManifests.push({ + path: relativePath, + parsed, + deps: new Set(pythonDepsFromPyproject(parsed)), + }); + } + for (const relativePath of pathsFor("requirements.txt")) { + pythonManifests.push({ + path: relativePath, + deps: new Set( + pythonDepsFromRequirements( + await readFileSafe(resolve(root, relativePath)) + ) + ), + }); + } + + // Languages: manifest/marker files first, then JS/TS from node manifests. + const languages = new Set(); + if ( + pathsFor("package.json").length > 0 || + pathsFor("tsconfig.json").length > 0 + ) { + languages.add("JavaScript"); + } + if ( + pathsFor("tsconfig.json").length > 0 || + nodeManifests.some((manifest) => manifest.deps.has("typescript")) + ) { + languages.add("TypeScript"); + } + for (const marker of LANGUAGE_MARKERS) { + if (marker.files.some((file) => pathsFor(file).length > 0)) { + languages.add(marker.language); + } + } + + // Linters: config files unconditionally; dependency evidence from the + // manifests of the linter's own language. A detected linter contributes its + // language(s). + const linters: DetectedLinter[] = []; + for (const signal of LINTER_SIGNALS) { + const evidence: string[] = []; + const servesPython = signal.languages.includes("Python"); + const servesNode = + signal.languages.includes("JavaScript") || + signal.languages.includes("TypeScript"); + + for (const configFile of signal.configFiles ?? []) { + evidence.push(...pathsFor(configFile)); + } + for (const table of signal.pyprojectTables ?? []) { + for (const manifest of pythonManifests) { + if (pyprojectHasTable(manifest.parsed, table)) { + evidence.push(`${manifest.path} [tool.${table}]`); + } + } + } + for (const dep of signal.deps ?? []) { + const manifests = servesPython + ? pythonManifests + : servesNode + ? nodeManifests + : []; + for (const manifest of manifests) { + if (manifest.deps.has(dep)) { + evidence.push(`dependency ${dep} (${manifest.path})`); + } + } + } + if (signal.name === "eslint") { + for (const manifest of nodeManifests) { + if (manifest.hasEslintConfigKey) { + evidence.push(`${manifest.path} (eslintConfig)`); + } + } + } + + if (evidence.length > 0) { + linters.push({ name: signal.name, evidence }); + for (const language of signal.languages) languages.add(language); + } + } - return { linters, languages, frameworks, ruleStyles }; + return { + linters, + languages: [...languages], + ruleStyles: detectRuleStyles(root, nodeManifests), + }; } diff --git a/packages/cli/src/schemas/detect.ts b/packages/cli/src/schemas/detect.ts index fb54e955..1fd59e76 100644 --- a/packages/cli/src/schemas/detect.ts +++ b/packages/cli/src/schemas/detect.ts @@ -6,7 +6,7 @@ const detectedLinterSchema = z.object({ evidence: z .array(z.string()) .describe( - "On-disk evidence: config-file paths, a pyproject table marker, or a package.json dependency marker (not all entries are file paths)" + "On-disk evidence: config-file paths, a pyproject table marker, or a dependency marker from the language's package file (not all entries are file paths)" ), }); @@ -28,10 +28,7 @@ export const outputSchema = z.object({ .describe("Linters configured in the working directory"), languages: z .array(z.string()) - .describe("Languages inferred from manifests and source signals"), - frameworks: z - .array(z.string()) - .describe("Frameworks inferred from dependency manifests"), + .describe("Languages inferred from manifests and detected linters"), ruleStyles: z .array(ruleStyleSchema) .describe("Styles of the repo's own existing rules"), diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts index 1f572a25..9baf0161 100644 --- a/packages/cli/test/detect.test.ts +++ b/packages/cli/test/detect.test.ts @@ -45,7 +45,6 @@ interface DetectJson { success: boolean; linters: { name: string; evidence: string[] }[]; languages: string[]; - frameworks: string[]; ruleStyles: { source: string; description: string }[]; } @@ -114,7 +113,7 @@ describe("taskless detect", () => { expect(linterNames(result)).toContain("stylelint"); }); - it("infers languages and frameworks from package.json", async () => { + it("infers languages from package.json", async () => { await writeFile( join(cwd, "package.json"), JSON.stringify({ @@ -127,9 +126,79 @@ describe("taskless detect", () => { expect(result.languages).toEqual( expect.arrayContaining(["JavaScript", "TypeScript"]) ); - expect(result.frameworks).toEqual( - expect.arrayContaining(["React", "Next.js"]) + }); + + it("detects ruff from a pyproject [project] dependency", async () => { + await writeFile( + join(cwd, "pyproject.toml"), + '[project]\nname = "x"\ndependencies = ["ruff>=0.4", "requests"]\n', + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).toContain("ruff"); + expect(result.languages).toContain("Python"); + }); + + it("detects flake8 from a requirements.txt entry", async () => { + await writeFile( + join(cwd, "requirements.txt"), + "# linting\nflake8==7.0.0\nrequests>=2\n", + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).toContain("flake8"); + expect(result.languages).toContain("Python"); + }); + + it("does not look up a Python linter dependency in package.json", async () => { + // A node manifest naming `ruff` must not register the Python linter — deps + // are sourced from the language's own manifest, never conflated. + await writeFile( + join(cwd, "package.json"), + JSON.stringify({ dependencies: { ruff: "^1.0.0" } }), + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).not.toContain("ruff"); + }); + + it("degrades gracefully on a malformed pyproject.toml", async () => { + // A TOML parse failure drops only the pyproject-derived signal; the file's + // presence still marks Python and a config file is an independent tell. + await writeFile( + join(cwd, "pyproject.toml"), + "this is = = not valid toml [[[\n", + "utf8" ); + await writeFile(join(cwd, "ruff.toml"), "line-length = 88\n", "utf8"); + const result = await detect(cwd); + expect(result.success).toBe(true); + expect(result.languages).toContain("Python"); + expect(linterNames(result)).toContain("ruff"); + }); + + it("detects a linter configured in a sub-package (monorepo)", async () => { + await mkdir(join(cwd, "packages", "api"), { recursive: true }); + await writeFile( + join(cwd, "packages", "api", ".eslintrc.json"), + "{}", + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).toContain("eslint"); + const eslint = result.linters.find((l) => l.name === "eslint"); + expect(eslint?.evidence).toContain("packages/api/.eslintrc.json"); + }); + + it("ignores linter configs inside node_modules", async () => { + await mkdir(join(cwd, "node_modules", "some-dep"), { recursive: true }); + await writeFile( + join(cwd, "node_modules", "some-dep", ".eslintrc.json"), + "{}", + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).not.toContain("eslint"); }); it("surfaces the repo's own Taskless rule styles", async () => { @@ -144,7 +213,7 @@ describe("taskless detect", () => { await writeFile(join(cwd, ".eslintrc.json"), "{}", "utf8"); const result = await detect(cwd); expect(Object.keys(result).toSorted()).toEqual( - ["frameworks", "languages", "linters", "ruleStyles", "success"].toSorted() + ["languages", "linters", "ruleStyles", "success"].toSorted() ); // A linter entry exposes only name + evidence, never a rule-name claim. for (const linter of result.linters) { @@ -179,12 +248,13 @@ describe("taskless detect", () => { // `dependencies` as an array (not an object) must not yield bogus deps. await writeFile( join(cwd, "package.json"), - JSON.stringify({ dependencies: ["react"], devDependencies: null }), + JSON.stringify({ dependencies: ["eslint"], devDependencies: null }), "utf8" ); const result = await detect(cwd); expect(result.success).toBe(true); - expect(result.frameworks).toEqual([]); + // The array form yields no dependency names, so no dep-based linter. + expect(linterNames(result)).not.toContain("eslint"); }); it("runs successfully with no linters, no network, and no auth", async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ae84903..bbbc1243 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: posthog-node: specifier: ^5.28.11 version: 5.28.11 + smol-toml: + specifier: ^1.6.1 + version: 1.6.1 sprintf-js: specifier: ^1.1.3 version: 1.1.3 @@ -2024,6 +2027,10 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4195,6 +4202,8 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + smol-toml@1.6.1: {} + source-map-js@1.2.1: {} spawndamnit@3.0.1: From 0d05e94d3d7f9d56e3654ccbb5ef237cfd87490e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 20:31:07 -0700 Subject: [PATCH 21/28] docs(cli): Update route recipe detect example for the new shape Drop the frameworks field from the detect output example and note the scan is monorepo-aware (evidence can carry a sub-package path). Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/help/route.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/help/route.txt b/packages/cli/src/help/route.txt index 954c3f8c..b1ece4ed 100644 --- a/packages/cli/src/help/route.txt +++ b/packages/cli/src/help/route.txt @@ -21,15 +21,15 @@ failed and the user confirms. ``` npx @taskless/cli detect --json ``` - This returns the configured linters, languages/frameworks, and the - repo's own rule styles. It is deterministic and offline — use it as - ground truth instead of guessing the repo's tooling. The output shape: + This returns the configured linters, languages, and the repo's own + rule styles. It is deterministic and offline — use it as ground truth + instead of guessing the repo's tooling. The scan is monorepo-aware, so + evidence may carry a sub-package path. The output shape: ```json { "success": true, - "linters": [{ "name": "eslint", "evidence": [".eslintrc.json"] }], + "linters": [{ "name": "eslint", "evidence": ["packages/api/.eslintrc.json"] }], "languages": ["JavaScript", "TypeScript"], - "frameworks": ["React"], "ruleStyles": [ { "source": ".taskless/rules", "description": "Existing Taskless ast-grep rules." } ] From 3d838a9f2bd4c3b94bae415bdd5cc5c227e27bd0 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 21:01:56 -0700 Subject: [PATCH 22/28] docs(cli): Sync cli-detect spec to new shape; document test layers - Mirror the detect contract changes into the synced cli-detect capability spec: drop frameworks, add the languages-only / per-language-manifest / monorepo scenarios. - Add a README testing section clarifying the two kinds of test: the local vitest suite (deterministic, no agent) versus the route-honesty dataset, whose automated test only guards the fixture structure while the actual agent evaluation is a separate manual calibration step. Co-Authored-By: Claude Opus 4.8 --- openspec/specs/cli-detect/spec.md | 38 ++++++++++++++++++++++++------- packages/cli/README.md | 24 +++++++++++++++++++ 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/openspec/specs/cli-detect/spec.md b/openspec/specs/cli-detect/spec.md index a9b33f3f..8fef84e5 100644 --- a/openspec/specs/cli-detect/spec.md +++ b/openspec/specs/cli-detect/spec.md @@ -21,9 +21,16 @@ flag. ### Requirement: Detect scans deterministic repo signals only The `detect` command SHALL emit only deterministic signals derived from files on -disk: configured linters, detected languages/frameworks, and the styles of the -repo's own existing rules. It SHALL NOT perform any LLM inference and SHALL NOT -match the request against any catalog of known packaged linter rules. +disk: configured linters, detected languages, and the styles of the repo's own +existing rules. It SHALL NOT perform any LLM inference and SHALL NOT match the +request against any catalog of known packaged linter rules. + +Detection follows a languages → linters flow: languages are inferred first, and +a linter's dependency evidence is then read from the manifest of that linter's +own language (a node dependency from `package.json`, a Python dependency from +`pyproject.toml`/`requirements.txt`) rather than conflating ecosystems. A +recognized linter config file on disk is honored regardless of the languages +inferred. #### Scenario: Linter configs are detected from disk @@ -32,11 +39,27 @@ match the request against any catalog of known packaged linter rules. `pyproject.toml`, `.rubocop.yml`, `biome.json`, or `stylelint` config) - **THEN** `detect --json` SHALL report each configured linter it found -#### Scenario: Languages and frameworks are reported +#### Scenario: Languages are reported - **WHEN** `detect --json` runs in a repository -- **THEN** the output SHALL include the languages and frameworks inferred from - manifest and source signals present on disk +- **THEN** the output SHALL include the languages inferred from manifest and + marker files present on disk and from the linters detected + +#### Scenario: A linter dependency is sourced from its own language's manifest + +- **WHEN** a dependency-evidenced linter (for example `ruff`) is named only in a + manifest belonging to a different language (for example `package.json`) +- **THEN** `detect --json` SHALL NOT report that linter from the mismatched + manifest + +#### Scenario: Configs in monorepo sub-packages are detected + +- **WHEN** a linter config or language manifest lives in a sub-package rather + than the repository root (for example `packages/api/.eslintrc.json`) +- **THEN** `detect --json` SHALL detect it and SHALL carry the path it was found + at in the linter's evidence +- **AND** the scan SHALL prune a curated set of ignored directories (for example + `node_modules`, `.git`, build output) and SHALL bound traversal depth #### Scenario: The repo's own rule styles are surfaced @@ -74,5 +97,4 @@ published artifact, and `detect` does not expose a `--schema` mode. - **WHEN** `detect --json` succeeds - **THEN** stdout SHALL be a single JSON object that the command has validated - against its internal output schema (linters, languages/frameworks, existing - rule styles) + against its internal output schema (linters, languages, existing rule styles) diff --git a/packages/cli/README.md b/packages/cli/README.md index 15e05f6c..db8635e2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -176,6 +176,30 @@ All commands output structured JSON to stdout by default. Parse with `JSON.parse ## Developing +### Testing + +```bash +pnpm --filter @taskless/cli test # run the suite once +pnpm --filter @taskless/cli exec vitest # watch mode +``` + +The suite runs entirely locally under vitest — no network, no auth, no agent. +Integration tests that exercise the built binary (for example `detect`) run +against `dist/`, so run `pnpm --filter @taskless/cli build` first (or after any +source change) before invoking them directly. + +**Two kinds of test, one of which is not fully automatable.** Most tests are +deterministic unit/integration checks. The route-honesty dataset +(`test/fixtures/route-eval.json`) is different: the actual routing decision is +made by an _agent_ following `help/route.txt`, so it cannot be asserted by a +code classifier. The automated test (`test/route-eval.test.ts`) therefore only +**guards the dataset** — that it stays structurally valid and balanced across +every route and both failure directions (over-claim / over-escalate). Running +the dataset _as an evaluation_ — feeding each case to an agent and scoring its +chosen destination — is a separate, manual calibration step with more setup; it +is not part of `pnpm test`. Keep the two distinct: the suite proves the fixtures +are well-formed; an agent run proves the recipe routes honestly. + ### API base URL The CLI resolves the API base URL in this order: From b4c50eb1b43fe7548c6129b6f017942b0b02491c Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 21:26:02 -0700 Subject: [PATCH 23/28] feat(cli): Add linters for already-detected Go, Rust, and PHP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detect recognized these languages but reported zero linters for them. Fill the obvious gaps with config-file signals: golangci-lint (Go), Clippy (Rust), and PHPStan / PHP_CodeSniffer / Psalm (PHP). Still tool-level signal, no rule catalog — consistent with the D3 non-goal. Co-Authored-By: Claude Opus 4.8 --- .changeset/detect-monorepo-node22.md | 3 +++ packages/cli/src/detect/scan.ts | 35 ++++++++++++++++++++++++++++ packages/cli/test/detect.test.ts | 24 +++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/.changeset/detect-monorepo-node22.md b/.changeset/detect-monorepo-node22.md index 020068d2..0a780825 100644 --- a/.changeset/detect-monorepo-node22.md +++ b/.changeset/detect-monorepo-node22.md @@ -18,3 +18,6 @@ Require Node.js 22+ and make `taskless detect` monorepo-aware. own signal. - **Dropped the `frameworks` field** from `detect` output. The routing recipe never consumed it; the contract now matches its sole consumer. +- **Filled obvious linter gaps** for languages detect already recognizes: + golangci-lint (Go), Clippy (Rust), and PHPStan / PHP_CodeSniffer / Psalm + (PHP). diff --git a/packages/cli/src/detect/scan.ts b/packages/cli/src/detect/scan.ts index 2d1d71f9..8577d32b 100644 --- a/packages/cli/src/detect/scan.ts +++ b/packages/cli/src/detect/scan.ts @@ -181,6 +181,41 @@ const LINTER_SIGNALS: readonly LinterSignal[] = [ languages: ["Ruby"], configFiles: [".rubocop.yml", ".rubocop.yaml"], }, + { + name: "golangci-lint", + languages: ["Go"], + configFiles: [ + ".golangci.yml", + ".golangci.yaml", + ".golangci.toml", + ".golangci.json", + ], + }, + { + name: "clippy", + languages: ["Rust"], + configFiles: ["clippy.toml", ".clippy.toml"], + }, + { + name: "phpstan", + languages: ["PHP"], + configFiles: ["phpstan.neon", "phpstan.neon.dist", "phpstan.dist.neon"], + }, + { + name: "php_codesniffer", + languages: ["PHP"], + configFiles: [ + "phpcs.xml", + "phpcs.xml.dist", + ".phpcs.xml", + ".phpcs.xml.dist", + ], + }, + { + name: "psalm", + languages: ["PHP"], + configFiles: ["psalm.xml", "psalm.xml.dist"], + }, { name: "clang-tidy", languages: ["C", "C++"], configFiles: [".clang-tidy"] }, { name: "swiftlint", diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts index 9baf0161..924d44bf 100644 --- a/packages/cli/test/detect.test.ts +++ b/packages/cli/test/detect.test.ts @@ -97,6 +97,30 @@ describe("taskless detect", () => { expect(result.languages).toContain("Ruby"); }); + it("detects golangci-lint for a Go repo", async () => { + await writeFile(join(cwd, "go.mod"), "module example.com/x\n", "utf8"); + await writeFile(join(cwd, ".golangci.yml"), "", "utf8"); + const result = await detect(cwd); + expect(linterNames(result)).toContain("golangci-lint"); + expect(result.languages).toContain("Go"); + }); + + it("detects phpstan for a PHP repo", async () => { + await writeFile(join(cwd, "composer.json"), "{}", "utf8"); + await writeFile(join(cwd, "phpstan.neon"), "", "utf8"); + const result = await detect(cwd); + expect(linterNames(result)).toContain("phpstan"); + expect(result.languages).toContain("PHP"); + }); + + it("detects clippy for a Rust repo", async () => { + await writeFile(join(cwd, "Cargo.toml"), '[package]\nname = "x"\n', "utf8"); + await writeFile(join(cwd, "clippy.toml"), "", "utf8"); + const result = await detect(cwd); + expect(linterNames(result)).toContain("clippy"); + expect(result.languages).toContain("Rust"); + }); + it("detects biome from biome.json", async () => { await writeFile(join(cwd, "biome.json"), "{}", "utf8"); const result = await detect(cwd); From d30341c47c10468ba21c5d275c6be9dd074c38a6 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 21:28:45 -0700 Subject: [PATCH 24/28] chore(cli): Anchor the telemetry-taxonomy reconciliation in code When the restructure-cli-telemetry change lands, detect/help must conform to the cli_run + cli_help taxonomy. Mark both sites with a shared TODO(telemetry-taxonomy) tag: drop the silent cli_detect capture in detect.ts (the easy-to-miss half) and convert the help_ assertions (which fail on rebase, so they self-surface). Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/detect.ts | 6 ++++++ packages/cli/test/help-routing-telemetry.test.ts | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/packages/cli/src/commands/detect.ts b/packages/cli/src/commands/detect.ts index c32756fe..7a0ff294 100644 --- a/packages/cli/src/commands/detect.ts +++ b/packages/cli/src/commands/detect.ts @@ -28,6 +28,12 @@ export const detectCommand = defineCommand({ async run({ args }) { const cwd = resolve(args.dir ?? process.cwd()); const telemetry = await getTelemetry(cwd); + // TODO(telemetry-taxonomy): once the restructure-cli-telemetry change lands + // on main, drop this bespoke cli_detect capture. detect is a read-only + // command with no state transition, so it rides on the cli_run denominator + // alone (the new taxonomy lists info/detect among cli_run-only commands). + // This emit is silent under the new taxonomy — nothing fails if it lingers — + // so it is the reconciliation's easy-to-miss half. See the telemetry stack. telemetry.capture("cli_detect"); const result = { diff --git a/packages/cli/test/help-routing-telemetry.test.ts b/packages/cli/test/help-routing-telemetry.test.ts index 79437851..29414b17 100644 --- a/packages/cli/test/help-routing-telemetry.test.ts +++ b/packages/cli/test/help-routing-telemetry.test.ts @@ -1,5 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +// TODO(telemetry-taxonomy): once the restructure-cli-telemetry change lands on +// main, the per-topic `help_` events collapse into a single +// `cli_help { topic }`. These assertions then move to `cli_help` with a topic +// property, and the help command stops emitting bespoke per-topic names. This +// half is self-surfacing: against the new help command this suite fails, so the +// rebase can't silently skip it. See the telemetry stack's analytics spec. +// // Spy on the telemetry capture by mocking the telemetry module the help // command imports. The factory is invoked lazily at import time, so the // closure over `capture` resolves after initialization (same pattern as From 26c7ced592980cc0e1bccbabfea0fee853b1c148 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 21:32:40 -0700 Subject: [PATCH 25/28] chore(cli): Point the telemetry-taxonomy TODOs at issue #39 Reference the tracking issue (#39) from both reconciliation anchors so the context lives next to the code. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/detect.ts | 4 ++-- packages/cli/test/help-routing-telemetry.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/detect.ts b/packages/cli/src/commands/detect.ts index 7a0ff294..6b4fb13f 100644 --- a/packages/cli/src/commands/detect.ts +++ b/packages/cli/src/commands/detect.ts @@ -28,8 +28,8 @@ export const detectCommand = defineCommand({ async run({ args }) { const cwd = resolve(args.dir ?? process.cwd()); const telemetry = await getTelemetry(cwd); - // TODO(telemetry-taxonomy): once the restructure-cli-telemetry change lands - // on main, drop this bespoke cli_detect capture. detect is a read-only + // TODO(#39): once the restructure-cli-telemetry change lands on main, drop + // this bespoke cli_detect capture. detect is a read-only // command with no state transition, so it rides on the cli_run denominator // alone (the new taxonomy lists info/detect among cli_run-only commands). // This emit is silent under the new taxonomy — nothing fails if it lingers — diff --git a/packages/cli/test/help-routing-telemetry.test.ts b/packages/cli/test/help-routing-telemetry.test.ts index 29414b17..de6e7c20 100644 --- a/packages/cli/test/help-routing-telemetry.test.ts +++ b/packages/cli/test/help-routing-telemetry.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -// TODO(telemetry-taxonomy): once the restructure-cli-telemetry change lands on -// main, the per-topic `help_` events collapse into a single +// TODO(#39): once the restructure-cli-telemetry change lands on main, the +// per-topic `help_` events collapse into a single // `cli_help { topic }`. These assertions then move to `cli_help` with a topic // property, and the help command stops emitting bespoke per-topic names. This // half is self-surfacing: against the new help command this suite fails, so the From d60e74fcb478edde686054a08d5f015ed990e06e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 22:06:47 -0700 Subject: [PATCH 26/28] =?UTF-8?q?chore(cli):=20Resolve=20#39=20=E2=80=94?= =?UTF-8?q?=20conform=20detect/help=20to=20the=20cli=5F=20taxonomy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The telemetry restructure has landed on main, so the detect stack now adopts the new event taxonomy: - detect.ts: drop the bespoke cli_detect capture. detect is read-only, so the per-invocation cli_run denominator (from the runner) covers it. Removes the now-unused getTelemetry import. - help-routing-telemetry.test.ts: the per-topic help_ events collapsed into cli_help { topic }; assert cli_help for the routing topics. Clears both TODO(#39) anchors. Closes #39. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/detect.ts | 12 +++--------- packages/cli/test/help-routing-telemetry.test.ts | 13 +++---------- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/commands/detect.ts b/packages/cli/src/commands/detect.ts index 6b4fb13f..f0be6b32 100644 --- a/packages/cli/src/commands/detect.ts +++ b/packages/cli/src/commands/detect.ts @@ -4,7 +4,6 @@ import { defineCommand } from "citty"; import { detectRepository } from "../detect/scan"; import { outputSchema as detectOutputSchema } from "../schemas/detect"; -import { getTelemetry } from "../telemetry"; import { makeErrorEnvelope } from "../types/errors"; export const detectCommand = defineCommand({ @@ -27,14 +26,9 @@ export const detectCommand = defineCommand({ }, async run({ args }) { const cwd = resolve(args.dir ?? process.cwd()); - const telemetry = await getTelemetry(cwd); - // TODO(#39): once the restructure-cli-telemetry change lands on main, drop - // this bespoke cli_detect capture. detect is a read-only - // command with no state transition, so it rides on the cli_run denominator - // alone (the new taxonomy lists info/detect among cli_run-only commands). - // This emit is silent under the new taxonomy — nothing fails if it lingers — - // so it is the reconciliation's easy-to-miss half. See the telemetry stack. - telemetry.capture("cli_detect"); + // detect is read-only with no state transition, so it emits no bespoke + // event — the per-invocation cli_run denominator (emitted by the runner) + // covers it, consistent with info under the cli_ telemetry taxonomy. const result = { success: true as const, diff --git a/packages/cli/test/help-routing-telemetry.test.ts b/packages/cli/test/help-routing-telemetry.test.ts index de6e7c20..ece49ffe 100644 --- a/packages/cli/test/help-routing-telemetry.test.ts +++ b/packages/cli/test/help-routing-telemetry.test.ts @@ -1,12 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -// TODO(#39): once the restructure-cli-telemetry change lands on main, the -// per-topic `help_` events collapse into a single -// `cli_help { topic }`. These assertions then move to `cli_help` with a topic -// property, and the help command stops emitting bespoke per-topic names. This -// half is self-surfacing: against the new help command this suite fails, so the -// rebase can't silently skip it. See the telemetry stack's analytics spec. -// // Spy on the telemetry capture by mocking the telemetry module the help // command imports. The factory is invoked lazily at import time, so the // closure over `capture` resolves after initialization (same pattern as @@ -31,7 +24,7 @@ interface RunnableCommand { }) => Promise; } -describe("help routing topics emit help_ intent telemetry", () => { +describe("help routing topics emit cli_help intent telemetry", () => { let logSpy: ReturnType; beforeEach(() => { @@ -45,7 +38,7 @@ describe("help routing topics emit help_ intent telemetry", () => { }); it.each(["route", "existing", "static", "remote"])( - "captures help_%s", + "captures cli_help for %s", async (topic) => { const command = createHelpCommand({}) as unknown as RunnableCommand; await command.run({ @@ -54,7 +47,7 @@ describe("help routing topics emit help_ intent telemetry", () => { }); expect(capture).toHaveBeenCalledWith( - `help_${topic}`, + "cli_help", expect.objectContaining({ topic }) ); } From e69d88e56318afcf6b78ec52dd5854e561596e67 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 22:11:14 -0700 Subject: [PATCH 27/28] chore(cli): Drop cli_detect on the detect branch (telemetry taxonomy) Conform detect.ts to the cli_ taxonomy now on main: detect is read-only and rides on cli_run, so the bespoke cli_detect capture and its getTelemetry import go. Matches the tip's #39 resolution so the branch is green/consistent. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/detect.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/detect.ts b/packages/cli/src/commands/detect.ts index c32756fe..f0be6b32 100644 --- a/packages/cli/src/commands/detect.ts +++ b/packages/cli/src/commands/detect.ts @@ -4,7 +4,6 @@ import { defineCommand } from "citty"; import { detectRepository } from "../detect/scan"; import { outputSchema as detectOutputSchema } from "../schemas/detect"; -import { getTelemetry } from "../telemetry"; import { makeErrorEnvelope } from "../types/errors"; export const detectCommand = defineCommand({ @@ -27,8 +26,9 @@ export const detectCommand = defineCommand({ }, async run({ args }) { const cwd = resolve(args.dir ?? process.cwd()); - const telemetry = await getTelemetry(cwd); - telemetry.capture("cli_detect"); + // detect is read-only with no state transition, so it emits no bespoke + // event — the per-invocation cli_run denominator (emitted by the runner) + // covers it, consistent with info under the cli_ telemetry taxonomy. const result = { success: true as const, From a797a39e867bd22c563d972e1f78db1eed1cea74 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 22:12:11 -0700 Subject: [PATCH 28/28] test(cli): Assert cli_help for routing topics (telemetry taxonomy) help.ts now emits cli_help { topic } instead of per-topic help_ (the taxonomy on main). Update the routing-topic assertions to match so this branch is green/consistent with the tip's #39 resolution. Co-Authored-By: Claude Opus 4.8 --- packages/cli/test/help-routing-telemetry.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/test/help-routing-telemetry.test.ts b/packages/cli/test/help-routing-telemetry.test.ts index 79437851..ece49ffe 100644 --- a/packages/cli/test/help-routing-telemetry.test.ts +++ b/packages/cli/test/help-routing-telemetry.test.ts @@ -24,7 +24,7 @@ interface RunnableCommand { }) => Promise; } -describe("help routing topics emit help_ intent telemetry", () => { +describe("help routing topics emit cli_help intent telemetry", () => { let logSpy: ReturnType; beforeEach(() => { @@ -38,7 +38,7 @@ describe("help routing topics emit help_ intent telemetry", () => { }); it.each(["route", "existing", "static", "remote"])( - "captures help_%s", + "captures cli_help for %s", async (topic) => { const command = createHelpCommand({}) as unknown as RunnableCommand; await command.run({ @@ -47,7 +47,7 @@ describe("help routing topics emit help_ intent telemetry", () => { }); expect(capture).toHaveBeenCalledWith( - `help_${topic}`, + "cli_help", expect.objectContaining({ topic }) ); }