Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .agents/skills/taskless/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
name: taskless
description: |
Use for any Taskless task. Trigger when the user mentions Taskless by name,
or when their request involves the .taskless/ directory or files in it
(rules, rule-tests, rule-metadata).

Specifically:
- "create/add/write a taskless rule for X"
- "improve/fix/iterate on this taskless rule"
- "delete/remove this taskless rule"
- "run taskless", "taskless check", "validate against taskless rules"
- "taskless login/logout/status", "is taskless connected"
- "add taskless to CI", "wire taskless into github actions"
- "onboard with taskless", "set up taskless for this project"

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
`npx @taskless/cli agent route`; it does NOT suppress the skill.
metadata:
type: shim
---

This is a Taskless reference stub. The canonical skill is defined at `.taskless/skills/taskless/SKILL.md`.

Read `.taskless/skills/taskless/SKILL.md` and follow its instructions.
26 changes: 13 additions & 13 deletions .conventions/STYLEGUIDE-CODE.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ interface GitHubComment {

### Export Types Referenced by Public API Signatures

**DO NOT** remove `export` from types that are transitively referenced by exported functions, values, or other exported types even if tools like knip report them as "unused exports." With `declaration: true` in `tsconfig`, TypeScript requires all types in exported signatures to be exported themselves.
**DO NOT** remove `export` from types that are transitively referenced by exported functions, values, or other exported types, even if tools like knip report them as "unused exports." With `declaration: true` in `tsconfig`, TypeScript requires all types in exported signatures to be exported themselves.

Before removing an `export` from a type, check whether any exported function or value references it in its signature (parameters, return types, or fields of other exported types).

Expand All @@ -150,7 +150,7 @@ interface LayerResult { ... } // breaks declaration emit for VerifyResult

- Knip tracks direct import usage, not transitive type reachability through exported signatures
- Removing these exports causes `declaration: true` to fail with "exported function has or is using private name" errors
- The fix is tedious each type must be re-exported individually, often across multiple review cycles
- The fix is tedious: each type must be re-exported individually, often across multiple review cycles

## Cross-Worker Durable Object Access

Expand Down Expand Up @@ -201,7 +201,7 @@ import type { UserDO, GitHubOrganizationDO } from "@taskless/storage";

### Verify Build Output In The Build, Not By Parsing It

**A failing build is still a valid test of the build.** When an invariant is about a build artifact, enforce it where the artifact is produced. If a bundle must not contain something, the build should refuse to emit it, rather than emitting it and leaving a test to go looking afterwards. An invariant enforced at production time cannot be violated; one enforced afterwards can only be detected.
**A failing build is still a valid test of the build.** When an invariant is about a build artifact, enforce it where the artifact is produced. If a bundle must not contain something, the build should refuse to emit it, rather than emitting it and leaving a test to go looking afterwards. An invariant enforced at production time cannot be violated; one enforced afterwards can only be detected.

**DO NOT** reconstruct a fact about generated output by parsing that output.

Expand Down Expand Up @@ -240,7 +240,7 @@ for (const specifier of specifiers) {
}
```

**Tests that _use_ a built artifact are fine.** Importing the built entry and asserting on its behavior, or spawning the built CLI and asserting on its output, are ordinary tests. The rule is not "tests must not touch build output" — it is that tests must not re-derive what the build already knew.
**Tests that _use_ a built artifact are fine.** Importing the built entry and asserting on its behavior, or spawning the built CLI and asserting on its output, are ordinary tests. The rule is not "tests must not touch build output". It is that tests must not re-derive what the build already knew.

```typescript
// ✅ Fine - uses the artifact, asserts on behavior
Expand All @@ -252,27 +252,27 @@ const { stdout } = await execFileAsync("node", [builtCli, "help"]);
expect(stdout).toContain("Usage:");
```

**Do not add a dependency in order to test an assertion.** If a test needs a parser to make sense of an artifact, that is the signal the check is in the wrong place the generator already has the structured data. Reach for a new devDependency only when several tests need it and nothing in the existing toolchain can answer the question.
**Do not add a dependency in order to test an assertion.** If a test needs a parser to make sense of an artifact, that is the signal the check is in the wrong place: the generator already has the structured data. Reach for a new devDependency only when several tests need it and nothing in the existing toolchain can answer the question.

**Worked example.** `packages/cli/test/prompts.test.ts` asserted that the built `dist/prompts.js` chunk graph never reaches the CLI entry or a host capability, by regex-scanning the built JavaScript for `from "…"` to reconstruct the import graph. A built chunk embeds every help recipe as a string literal, and the `engine-selection` recipe contains the phrase `a different axis from "which engine"` so the scan reported `dist/prompts.js graph imports which engine`. Prose was read as an import.
**Worked example.** `packages/cli/test/prompts.test.ts` asserted that the built `dist/prompts.js` chunk graph never reaches the CLI entry or a host capability, by regex-scanning the built JavaScript for `from "…"` to reconstruct the import graph. A built chunk embeds every help recipe as a string literal, and the `engine-selection` recipe contains the phrase `a different axis from "which engine"`, so the scan reported `dist/prompts.js graph imports which engine`. Prose was read as an import.

The fixes that did not work, and why:

| Attempt | Why it was rejected |
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Filter candidates by specifier shape (`/^(?:node:)?[@\w./-]+$/`) | Passed only because that phrase contains a space. Measured against the real bundle the regex yields `["which engine"]` and the filter drops it but `differs from "static-tier"` is a bare hyphenated name with no whitespace and would have been reported. The guard held by luck of punctuation. |
| Add `es-module-lexer` as a devDependency | Parsed the graph correctly, but bought a dependencyand a second major version, since vite already pulls 1.7.0 transitivelyto serve a single test. |
| Anchor the regex to line-start | Matched the lexer exactly on today's bundles, but required `from` on the same line as `import`. A future bundler that wrapped a long import would silently stop detecting real imports trading a loud false positive for a quiet false negative in the guard whose entire job is catching a leak. |
| Attempt | Why it was rejected |
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Filter candidates by specifier shape (`/^(?:node:)?[@\w./-]+$/`) | Passed only because that phrase contains a space. Measured against the real bundle the regex yields `["which engine"]` and the filter drops it, but `differs from "static-tier"` is a bare hyphenated name with no whitespace and would have been reported. The guard held by luck of punctuation. |
| Add `es-module-lexer` as a devDependency | Parsed the graph correctly, but bought a dependency, and a second major version since vite already pulls 1.7.0 transitively, to serve a single test. |
| Anchor the regex to line-start | Matched the lexer exactly on today's bundles, but required `from` on the same line as `import`. A future bundler that wrapped a long import would silently stop detecting real imports, trading a loud false positive for a quiet false negative in the guard whose entire job is catching a leak. |

The resolution: rollup's `OutputChunk` already exposes `imports` and `dynamicImports` the exact resolved graph. The check moved into a vite plugin that fails the build, and the test was deleted.
The resolution: rollup's `OutputChunk` already exposes `imports` and `dynamicImports`, the exact resolved graph. The check moved into a vite plugin that fails the build, and the test was deleted.

The same reasoning forbids adding a YAML parser to assert on generated config, or an HTML parser to assert on rendered output. In each case the generator knows the answer and the test is guessing at it.

**Rationale:**

- An invariant enforced at production time cannot be violated; one enforced afterwards can only be detected
- Parsing generated text reconstructs information the generator already had, using a weaker tool
- A check that needs a parser is a check in the wrong place move it to where the structured data lives
- A check that needs a parser is a check in the wrong place; move it to where the structured data lives
- A build that fails is a faster, earlier signal than a test that fails, and it cannot be skipped
- Regexes over generated output are brittle in the worst direction: they break on content that merely resembles code, and they quietly stop matching when the generator's formatting changes

Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/stack-breadcrumb.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ name: Stack Breadcrumb
on:
pull_request:
# Tree SHAPE only — no `synchronize` (a head push never changes membership).
types: [opened, reopened, edited, closed]
# `ready_for_review` is not in the default set and is named deliberately: a
# draft becoming ready is the moment the PR joins the reviewable stack, and
# without it the breadcrumb keeps describing the PR as a draft until some
# other event happens to fire.
types: [opened, reopened, edited, ready_for_review, closed]
repository_dispatch:
types: [stack-reconcile]
workflow_dispatch:
Expand Down
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ worktrees/

# The demo project: deliberately-wrong source and prose fixtures.
example/

# Taskless rule fixtures: deliberately-wrong prose and source.
.taskless/
4 changes: 3 additions & 1 deletion .taskless/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
.env.local.json
sgconfig.yml
/sgconfig.yml
/.vale.ini
/.sgconfig.yml
Empty file.
9 changes: 9 additions & 0 deletions .taskless/rules/sg/no-eval/.tests/no-eval-20260824-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
id: no-eval
valid:
- const config = JSON.parse(raw);
- const handler = handlers[name];
- const fn = () => compute(input);
invalid:
- eval(userInput);
- const fn = Function("return " + expression);
- const fn = new Function("a", "b", "return a + b");
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
id: no-eval
language: typescript
language: TypeScript
severity: error
message: Do not use eval() or Function() to evaluate strings as code. These are security risks that enable code injection attacks.
note: Use safer alternatives like JSON.parse() for data, or restructure code to avoid dynamic evaluation.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
id: no-index-imports
valid:
- import { runWizard } from "./wizard/wizard";
- import { getRecipe } from "./recipes.js";
- import { getSandbox } from "@cloudflare/sandbox";
- import { PostHog } from "posthog-node";
invalid:
- import { runWizard } from "./index";
- import { getRecipe } from "../src/prompts/index";
- import { buildInstallPlan } from "../install/index.js";
20 changes: 20 additions & 0 deletions .taskless/rules/sg/no-index-imports/no-index-imports.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
id: no-index-imports
language: TypeScript
severity: warning
message: Import directly from the source file, not from a barrel index.
note: |
Barrel exports hide where a symbol is defined, make tree-shaking less
predictable, and invite circular imports. Import the module that declares
the symbol instead of the `index` that re-exports it.

Third-party packages that publish a barrel as their public API are fine —
this rule only matches relative specifiers.
ignores:
- "**/test/**"
- "**/*.test.ts"
rule:
kind: string_fragment
regex: '^\.{1,2}(/[^/]+)*/index(\.js|\.ts)?$'
inside:
kind: import_statement
stopBy: end
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
id: no-pii-in-telemetry
valid:
- |
posthog.capture({
distinctId,
event: "cli_rule_create",
properties: { cli: xdgUuid, anonymous: false },
groups: { organization: orgId },
});
- |
posthog.identify({ distinctId, properties: { cli: xdgUuid } });
- |
const user = { email: account.email, displayName: account.name };
invalid:
- |
posthog.capture({
distinctId,
event: "cli_auth_login_completed",
properties: { cli: xdgUuid, email: account.email },
});
- |
posthog.identify({
distinctId,
properties: { cli: xdgUuid, displayName: account.name },
});
22 changes: 22 additions & 0 deletions .taskless/rules/sg/no-pii-in-telemetry/no-pii-in-telemetry.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
id: no-pii-in-telemetry
language: TypeScript
severity: error
message: Do not send PII in a telemetry call. Identify with internal IDs only.
note: |
PostHog identity uses `jwt.sub`, `jwt.orgId`, and the XDG anonymous UUID.
Email addresses, display names, and real names must never reach
`capture()`, `identify()`, or `groupIdentify()`.

See .conventions/posthog.md — Privacy.
rule:
kind: pair
has:
field: key
kind: property_identifier
regex: '^(email|userEmail|displayName|fullName|firstName|lastName|username)$'
inside:
stopBy: end
any:
- pattern: $CLIENT.capture($$$)
- pattern: $CLIENT.identify($$$)
- pattern: $CLIENT.groupIdentify($$$)
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
id: no-regex-over-build-output
valid:
- |
it("renders from the built artifact", async () => {
const builtEntry = join(root, "dist/prompts.js");
const { getPrompt } = await import(pathToFileURL(builtEntry).href);
expect(getPrompt("engine-selection")).toBe(sourceRecipe);
});
- |
it("spawns the built CLI", async () => {
const builtCli = join(root, "dist/index.js");
const { stdout } = await execFileAsync("node", [builtCli, "help"]);
expect(stdout).toContain("Usage:");
});
- |
function importSpecifiers(source: string): string[] {
const found = [...source.matchAll(/\bfrom\s*["']([^"']+)["']/g)];
return found.map((match) => match[1]!);
}
invalid:
- |
it("imports nothing forbidden", async () => {
const source = readFileSync(join(root, "dist/prompts.js"), "utf8");
const specifiers = [...source.matchAll(/\bfrom\s*["']([^"']+)["']/g)];
expect(specifiers).toEqual([]);
});
- |
it("bundles no node builtins", async () => {
const bundle = await readFile("dist/index.js", "utf8");
expect(bundle.match(/require\(["']node:fs["']\)/)).toBeNull();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
id: no-regex-over-build-output
language: TypeScript
severity: warning
message: Do not re-derive a fact about build output by regex-scanning it.
note: |
A test that reads from `dist/` and then runs a regex over the contents is
reconstructing something the build already knew, with a weaker tool. Move
the invariant into the build — a rollup/vite plugin can ask the resolved
chunk graph directly and fail the build.

Using a built artifact is fine: import it and assert on behavior, or spawn
the built CLI and assert on its output. This rule fires only on parsing it.

Scoped to the enclosing function, not the file: a helper that regexes
hand-written source is sound even when the same file elsewhere loads a
built artifact.

See .conventions/STYLEGUIDE-CODE.md — "Verify Build Output In The Build".
files:
- "**/test/**"
- "**/*.test.ts"
rule:
any:
- pattern: $SRC.matchAll($RE)
- pattern: $SRC.match($RE)
inside:
stopBy: end
any:
- kind: function_declaration
- kind: arrow_function
- kind: function_expression
- kind: method_definition
has:
stopBy: end
kind: string_fragment
regex: 'dist/'
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
id: pr-workflow-no-branches-filter
valid:
- |
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
- |
on:
push:
branches: [main]
paths:
- ".github/scripts/vale-manifest.json"
- |
on:
workflow_run:
workflows: [Validate]
types: [completed]
invalid:
- |
on:
pull_request:
branches: [main]
- |
on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
id: pr-workflow-no-branches-filter
language: Yaml
severity: error
message: A pull_request trigger must not carry a branches filter.
note: |
The filter matches the PR's base ref, but GitHub also resolves a stacked
PR's *eventual* target and sometimes matches on that instead. So
`branches: [main]` does run on mid-stack PRs — until it stops, with no
error and nothing turning red.

Measured on the #71→#93→#94→#95→#100→#102→#103→#106 stack: every PR up to
#102 got a `Validate` run and #103 and #106 got none, across 16
`pull_request` events that filter-less workflows handled fine. #103 was a
~93-file change that reached "ready for review" having never been linted,
typechecked, or tested in CI.

A workflow that must run everywhere carries no `branches:` filter at all.
A workflow whose correctness depends on "is this the PR that merges to
main" must determine that inside the job — from the base ref, or by
resolving stack position — not from the `on:` filter.

A `branches:` filter under `push:` is unaffected and correct.

See CLAUDE.md — "branches: filters do not tell you where a workflow runs".
files:
- ".github/workflows/*.yml"
- ".github/workflows/*.yaml"
rule:
kind: block_mapping_pair
has:
field: key
kind: flow_node
regex: '^branches$'
inside:
stopBy: end
kind: block_mapping_pair
has:
field: key
kind: flow_node
regex: '^pull_request$'
Loading
Loading