Skip to content
Merged
23 changes: 23 additions & 0 deletions .changeset/detect-monorepo-node22.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@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.
- **Filled obvious linter gaps** for languages detect already recognizes:
golangci-lint (Go), Clippy (Rust), and PHPStan / PHP_CodeSniffer / Psalm
(PHP).
25 changes: 21 additions & 4 deletions openspec/changes/local-rule-routing/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions openspec/changes/local-rule-routing/proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
38 changes: 30 additions & 8 deletions openspec/changes/local-rule-routing/specs/cli-detect/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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)
39 changes: 39 additions & 0 deletions openspec/changes/local-rule-routing/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
## 1. Detect command (cli-detect)

- [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 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)

- [ ] 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 <topic>`
- [ ] 3.2 Ensure `route`, `existing`, `static`, `remote` appear in the `taskless help` (no-arg) topic index
- [ ] 3.3 Verify `help_<topic>` 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
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"dist"
],
"engines": {
"node": "^20.20.0 || >=22.22.0"
"node": ">=22.22.0"
},
"dependencies": {
"@ast-grep/cli": "^0.41.0",
Expand All @@ -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"
Expand Down
77 changes: 77 additions & 0 deletions packages/cli/src/commands/detect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { resolve } from "node:path";

import { defineCommand } from "citty";

import { detectRepository } from "../detect/scan";
import { outputSchema as detectOutputSchema } from "../schemas/detect";
import { makeErrorEnvelope } from "../types/errors";

export const detectCommand = defineCommand({
meta: {
name: "detect",
description:
"Scan the repo for configured linters, languages, 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());
// 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,
...(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.evidence.join(", ")}`);
}
}

console.log(
`\nLanguages: ${result.languages.length > 0 ? result.languages.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}`);
}
}
},
});
Loading