diff --git a/.agents/skills/taskless/SKILL.md b/.agents/skills/taskless/SKILL.md new file mode 100644 index 00000000..f705205b --- /dev/null +++ b/.agents/skills/taskless/SKILL.md @@ -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. diff --git a/.conventions/STYLEGUIDE-CODE.md b/.conventions/STYLEGUIDE-CODE.md index bb149951..477219b5 100644 --- a/.conventions/STYLEGUIDE-CODE.md +++ b/.conventions/STYLEGUIDE-CODE.md @@ -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). @@ -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 @@ -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. @@ -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 @@ -252,19 +252,19 @@ 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 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. | +| 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. @@ -272,7 +272,7 @@ The same reasoning forbids adding a YAML parser to assert on generated config, o - 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 diff --git a/.github/workflows/stack-breadcrumb.yml b/.github/workflows/stack-breadcrumb.yml index 4dc6e67b..9f3c4e1e 100644 --- a/.github/workflows/stack-breadcrumb.yml +++ b/.github/workflows/stack-breadcrumb.yml @@ -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: diff --git a/.prettierignore b/.prettierignore index c2a0f1df..48984929 100644 --- a/.prettierignore +++ b/.prettierignore @@ -13,3 +13,6 @@ worktrees/ # The demo project: deliberately-wrong source and prose fixtures. example/ + +# Taskless rule fixtures: deliberately-wrong prose and source. +.taskless/ diff --git a/.taskless/.gitignore b/.taskless/.gitignore index b55464c7..f67703dc 100644 --- a/.taskless/.gitignore +++ b/.taskless/.gitignore @@ -1,2 +1,4 @@ .env.local.json -sgconfig.yml +/sgconfig.yml +/.vale.ini +/.sgconfig.yml diff --git a/.taskless/rules/runtime/.gitkeep b/.taskless/rules/runtime/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.taskless/rules/sg/no-eval/.tests/no-eval-20260824-test.yml b/.taskless/rules/sg/no-eval/.tests/no-eval-20260824-test.yml new file mode 100644 index 00000000..9adfa7f2 --- /dev/null +++ b/.taskless/rules/sg/no-eval/.tests/no-eval-20260824-test.yml @@ -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"); diff --git a/.taskless/rules/no-eval.yml b/.taskless/rules/sg/no-eval/no-eval.yml similarity index 94% rename from .taskless/rules/no-eval.yml rename to .taskless/rules/sg/no-eval/no-eval.yml index 2ab2906a..be9e01eb 100644 --- a/.taskless/rules/no-eval.yml +++ b/.taskless/rules/sg/no-eval/no-eval.yml @@ -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. diff --git a/.taskless/rules/sg/no-index-imports/.tests/no-index-imports-20260824-test.yml b/.taskless/rules/sg/no-index-imports/.tests/no-index-imports-20260824-test.yml new file mode 100644 index 00000000..7d434ec5 --- /dev/null +++ b/.taskless/rules/sg/no-index-imports/.tests/no-index-imports-20260824-test.yml @@ -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"; diff --git a/.taskless/rules/sg/no-index-imports/no-index-imports.yml b/.taskless/rules/sg/no-index-imports/no-index-imports.yml new file mode 100644 index 00000000..8c1ede3c --- /dev/null +++ b/.taskless/rules/sg/no-index-imports/no-index-imports.yml @@ -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 diff --git a/.taskless/rules/sg/no-pii-in-telemetry/.tests/no-pii-in-telemetry-20260824-test.yml b/.taskless/rules/sg/no-pii-in-telemetry/.tests/no-pii-in-telemetry-20260824-test.yml new file mode 100644 index 00000000..4e745f2e --- /dev/null +++ b/.taskless/rules/sg/no-pii-in-telemetry/.tests/no-pii-in-telemetry-20260824-test.yml @@ -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 }, + }); diff --git a/.taskless/rules/sg/no-pii-in-telemetry/no-pii-in-telemetry.yml b/.taskless/rules/sg/no-pii-in-telemetry/no-pii-in-telemetry.yml new file mode 100644 index 00000000..a0b537df --- /dev/null +++ b/.taskless/rules/sg/no-pii-in-telemetry/no-pii-in-telemetry.yml @@ -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($$$) diff --git a/.taskless/rules/sg/no-regex-over-build-output/.tests/no-regex-over-build-output-20260824-test.yml b/.taskless/rules/sg/no-regex-over-build-output/.tests/no-regex-over-build-output-20260824-test.yml new file mode 100644 index 00000000..78145a29 --- /dev/null +++ b/.taskless/rules/sg/no-regex-over-build-output/.tests/no-regex-over-build-output-20260824-test.yml @@ -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(); + }); diff --git a/.taskless/rules/sg/no-regex-over-build-output/no-regex-over-build-output.yml b/.taskless/rules/sg/no-regex-over-build-output/no-regex-over-build-output.yml new file mode 100644 index 00000000..e8c8685a --- /dev/null +++ b/.taskless/rules/sg/no-regex-over-build-output/no-regex-over-build-output.yml @@ -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/' diff --git a/.taskless/rules/sg/pr-workflow-no-branches-filter/.tests/pr-workflow-no-branches-filter-20260824-test.yml b/.taskless/rules/sg/pr-workflow-no-branches-filter/.tests/pr-workflow-no-branches-filter-20260824-test.yml new file mode 100644 index 00000000..5edc5906 --- /dev/null +++ b/.taskless/rules/sg/pr-workflow-no-branches-filter/.tests/pr-workflow-no-branches-filter-20260824-test.yml @@ -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] diff --git a/.taskless/rules/sg/pr-workflow-no-branches-filter/pr-workflow-no-branches-filter.yml b/.taskless/rules/sg/pr-workflow-no-branches-filter/pr-workflow-no-branches-filter.yml new file mode 100644 index 00000000..972f68f9 --- /dev/null +++ b/.taskless/rules/sg/pr-workflow-no-branches-filter/pr-workflow-no-branches-filter.yml @@ -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$' diff --git a/.taskless/rules/sg/pr-workflow-ready-for-review/.tests/pr-workflow-ready-for-review-20260824-test.yml b/.taskless/rules/sg/pr-workflow-ready-for-review/.tests/pr-workflow-ready-for-review-20260824-test.yml new file mode 100644 index 00000000..decb6ed4 --- /dev/null +++ b/.taskless/rules/sg/pr-workflow-ready-for-review/.tests/pr-workflow-ready-for-review-20260824-test.yml @@ -0,0 +1,27 @@ +id: pr-workflow-ready-for-review +valid: + - | + on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + - | + on: + push: + branches: [main] + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + - | + on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] +invalid: + - | + on: + pull_request: + types: [opened, synchronize, reopened] + - | + on: + pull_request: + types: [opened, reopened, edited, closed] diff --git a/.taskless/rules/sg/pr-workflow-ready-for-review/pr-workflow-ready-for-review.yml b/.taskless/rules/sg/pr-workflow-ready-for-review/pr-workflow-ready-for-review.yml new file mode 100644 index 00000000..f64e11d2 --- /dev/null +++ b/.taskless/rules/sg/pr-workflow-ready-for-review/pr-workflow-ready-for-review.yml @@ -0,0 +1,40 @@ +id: pr-workflow-ready-for-review +language: Yaml +severity: warning +message: A pull_request types list should name ready_for_review. +note: | + `ready_for_review` is not in the default set (`opened`, `synchronize`, + `reopened`), so a workflow that names `types:` at all must name it + explicitly or a draft marked ready gets no fresh run until something + happens to push again. + + That is the state #103 sat in: a ~93-file change reached "ready for + review" having never been linted, typechecked, or tested in CI. + + A workflow that reacts to PR metadata rather than to PR readiness may + legitimately omit it. Say so in the workflow if you do. + + See CLAUDE.md — "A workflow that must run everywhere carries no + branches: filter at all". +files: + - ".github/workflows/*.yml" + - ".github/workflows/*.yaml" +rule: + kind: block_mapping_pair + has: + field: key + kind: flow_node + regex: '^types$' + not: + has: + field: value + stopBy: end + kind: flow_node + regex: 'ready_for_review' + inside: + stopBy: end + kind: block_mapping_pair + has: + field: key + kind: flow_node + regex: '^pull_request$' diff --git a/.taskless/rules/vale/.gitkeep b/.taskless/rules/vale/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.taskless/rules/vale/comments-record-not-forecast/.tests/fail/probe.ts b/.taskless/rules/vale/comments-record-not-forecast/.tests/fail/probe.ts new file mode 100644 index 00000000..089431ac --- /dev/null +++ b/.taskless/rules/vale/comments-record-not-forecast/.tests/fail/probe.ts @@ -0,0 +1,6 @@ +// The docs describe the current Vale, and 3.18.0 is the known incoming bump. +export const VALE_VERSION = "3.17.1"; + +// None of these is reachable today, though once we upgrade the second tier +// will start routing differently. +export const TIERS = []; diff --git a/.taskless/rules/vale/comments-record-not-forecast/.tests/pass/probe.ts b/.taskless/rules/vale/comments-record-not-forecast/.tests/pass/probe.ts new file mode 100644 index 00000000..028109b4 --- /dev/null +++ b/.taskless/rules/vale/comments-record-not-forecast/.tests/pass/probe.ts @@ -0,0 +1,7 @@ +// Measured on the pinned 3.18.0 binary: a bare non-comment line in bare.pyi +// yields no finding, so the file is plaintext rather than markup. +export const VALE_VERSION = "3.18.0"; + +// Re-probed after the bump rather than renumbered. The claim is the binary's, +// not the release notes'. +export const TIERS = []; diff --git a/.taskless/rules/vale/comments-record-not-forecast/.vale.ini b/.taskless/rules/vale/comments-record-not-forecast/.vale.ini new file mode 100644 index 00000000..48d29942 --- /dev/null +++ b/.taskless/rules/vale/comments-record-not-forecast/.vale.ini @@ -0,0 +1,6 @@ +# Vale reads .ts in its comments-only tier: the comment text is linted and the +# code body is invisible, so this rule can never fire on an identifier. +[packages/cli/src/**/*.ts] +tskl) rule = comments-record-not-forecast +BasedOnStyles = +comments-record-not-forecast.comments-record-not-forecast = YES diff --git a/.taskless/rules/vale/comments-record-not-forecast/comments-record-not-forecast.yml b/.taskless/rules/vale/comments-record-not-forecast/comments-record-not-forecast.yml new file mode 100644 index 00000000..6594683c --- /dev/null +++ b/.taskless/rules/vale/comments-record-not-forecast/comments-record-not-forecast.yml @@ -0,0 +1,15 @@ +extends: existence +message: "'%s' forecasts. Record what was measured, and date the claim." +level: warning +ignorecase: true +tokens: + - 'the known incoming' + - 'the incoming bump' + - 'the upcoming release' + - 'in a future release' + - 'when we bump' + - 'once we upgrade' + - 'once we bump' + - 'we anticipate' + - 'is expected to become' + - 'will likely become' diff --git a/.taskless/rules/vale/docs-npx-cli/.tests/fail/README.md b/.taskless/rules/vale/docs-npx-cli/.tests/fail/README.md new file mode 100644 index 00000000..7a5c9e99 --- /dev/null +++ b/.taskless/rules/vale/docs-npx-cli/.tests/fail/README.md @@ -0,0 +1,13 @@ +# Getting started + +Run pnpm dlx @taskless/cli to install Taskless into this project. + +The inline form `pnpm dlx @taskless/cli@latest info` is a violation too: +commands in docs are almost always in code spans, so the rule has to see +them. + +```bash +pnpm dlx @taskless/cli@latest check +``` + +You can also invoke pnpm cli info to check the version. diff --git a/.taskless/rules/vale/docs-npx-cli/.tests/pass/README.md b/.taskless/rules/vale/docs-npx-cli/.tests/pass/README.md new file mode 100644 index 00000000..fb323cfc --- /dev/null +++ b/.taskless/rules/vale/docs-npx-cli/.tests/pass/README.md @@ -0,0 +1,12 @@ +# Getting started + +Run npx @taskless/cli to install Taskless into this project. + +The inline form `npx @taskless/cli@latest info` is correct. + +```bash +npx @taskless/cli@latest check +``` + +Other package managers are fine for unrelated work, such as pnpm install +or `pnpm build`, and are not what this rule is about. diff --git a/.taskless/rules/vale/docs-npx-cli/.vale.ini b/.taskless/rules/vale/docs-npx-cli/.vale.ini new file mode 100644 index 00000000..d1acb470 --- /dev/null +++ b/.taskless/rules/vale/docs-npx-cli/.vale.ini @@ -0,0 +1,22 @@ +# READMEs are read by external consumers, who do not have this repo's scripts. +# CLAUDE.md and .conventions/ deliberately document the local `pnpm cli` path. +# +# There is no per-case escape hatch for this rule, and that is a property of its +# scope rather than an oversight. `scope: [raw, ...]` in the rule is what reaches +# a fenced code block at all, and Vale evaluates a raw scope against the +# unparsed markup, so its in-file directives never apply: measured on Vale +# 3.18.0, both `` and a blanket +# `` are ignored here, while dropping `raw` makes both work and +# costs every fenced-block finding. A document that has to describe the local +# script therefore names it (see the root README) instead of reproducing the +# invocation, and the rule keeps its full reach. +[**/README.md] +tskl) rule = docs-npx-cli +BasedOnStyles = +docs-npx-cli.docs-npx-cli = YES + +# Test fixtures are inputs to the CLI's own suite, not documentation. +[**/test/fixtures/**/README.md] +tskl) rule = docs-npx-cli +BasedOnStyles = +docs-npx-cli.docs-npx-cli = NO diff --git a/.taskless/rules/vale/docs-npx-cli/docs-npx-cli.yml b/.taskless/rules/vale/docs-npx-cli/docs-npx-cli.yml new file mode 100644 index 00000000..a8f54022 --- /dev/null +++ b/.taskless/rules/vale/docs-npx-cli/docs-npx-cli.yml @@ -0,0 +1,8 @@ +extends: substitution +message: "Use '%s' instead of '%s' — docs use the normalized invocation for external consumers" +level: error +ignorecase: false +scope: [raw, code, text] +swap: + 'pnpm dlx @taskless/cli': npx @taskless/cli + 'pnpm cli': npx @taskless/cli diff --git a/.taskless/rules/vale/no-blocklist-phrases/.tests/fail/README.md b/.taskless/rules/vale/no-blocklist-phrases/.tests/fail/README.md new file mode 100644 index 00000000..32c5cd04 --- /dev/null +++ b/.taskless/rules/vale/no-blocklist-phrases/.tests/fail/README.md @@ -0,0 +1,9 @@ +# Notes + +You're absolutely right that the config is confusing. + +Good catch on the missing flag. + +The version pin is load-bearing, so leave it alone. + +To be honest, the bottom line is that we landed on the second option. diff --git a/.taskless/rules/vale/no-blocklist-phrases/.tests/pass/README.md b/.taskless/rules/vale/no-blocklist-phrases/.tests/pass/README.md new file mode 100644 index 00000000..37b27241 --- /dev/null +++ b/.taskless/rules/vale/no-blocklist-phrases/.tests/pass/README.md @@ -0,0 +1,9 @@ +# Notes + +The config is confusing, and the missing flag is a real bug. + +The version pin is what keeps the format table accurate, so leave it alone. + +The second option is what we chose, for the reasons below. + +PR #103 landed without a CI run, and the plane landed on time. diff --git a/.taskless/rules/vale/no-blocklist-phrases/.vale.ini b/.taskless/rules/vale/no-blocklist-phrases/.vale.ini new file mode 100644 index 00000000..260f2224 --- /dev/null +++ b/.taskless/rules/vale/no-blocklist-phrases/.vale.ini @@ -0,0 +1,24 @@ +# Scoped to READMEs. Broadening to source comments, recipe text, and openspec +# is a separate decision with a large remediation attached. +[**/README.md] +tskl) rule = no-blocklist-phrases +BasedOnStyles = +no-blocklist-phrases.no-blocklist-phrases = YES + +# Agent-facing instructions and the house conventions. Read as often as the +# READMEs are, by both people and agents, and small enough to keep conforming. +[CLAUDE.md] +tskl) rule = no-blocklist-phrases +BasedOnStyles = +no-blocklist-phrases.no-blocklist-phrases = YES + +[.conventions/*.md] +tskl) rule = no-blocklist-phrases +BasedOnStyles = +no-blocklist-phrases.no-blocklist-phrases = YES + +# Test fixtures are inputs to the CLI's own suite, not documentation. +[**/test/fixtures/**/README.md] +tskl) rule = no-blocklist-phrases +BasedOnStyles = +no-blocklist-phrases.no-blocklist-phrases = NO diff --git a/.taskless/rules/vale/no-blocklist-phrases/no-blocklist-phrases.yml b/.taskless/rules/vale/no-blocklist-phrases/no-blocklist-phrases.yml new file mode 100644 index 00000000..80945456 --- /dev/null +++ b/.taskless/rules/vale/no-blocklist-phrases/no-blocklist-phrases.yml @@ -0,0 +1,33 @@ +extends: existence +message: "'%s' is on the house blocklist. Say the thing plainly instead." +level: error +ignorecase: true +tokens: + # Reflexive agreement openers + - "you[''’]re absolutely right" + - "you[''’]re right" + - 'great point' + - 'good catch' + # Borrowed voice + - 'it hits different' + - 'the one thing I keep coming back to' + - 'I found the smoking gun' + - 'bottom line' + - 'load-bearing' + - 'belt and suspenders' + # Filler intensifiers + - 'and honestly' + # Performative candor + - 'the honest truth' + - "let[''’]s be honest" + - 'to be honest' + - 'the hard truth' + - 'real talk' + # "land" for a decision or agreement. The literal senses are fine (a plane + # lands, a PR lands), so only the decision collocations are listed. A bare + # 'landed on' was measured firing on "the plane landed on time"; 'we landed' + # already covers "we landed on the second option", so it earned nothing. + - 'we landed' + - 'what we landed' + - 'the decision landed' + - 'glad it landed' diff --git a/.taskless/rules/vale/no-em-dashes/.tests/fail/README.md b/.taskless/rules/vale/no-em-dashes/.tests/fail/README.md new file mode 100644 index 00000000..46655820 --- /dev/null +++ b/.taskless/rules/vale/no-em-dashes/.tests/fail/README.md @@ -0,0 +1,5 @@ +# Setup + +The installer writes two files — the config and the manifest. + +An en dash used the same way – like this – is the same problem. diff --git a/.taskless/rules/vale/no-em-dashes/.tests/pass/README.md b/.taskless/rules/vale/no-em-dashes/.tests/pass/README.md new file mode 100644 index 00000000..dac90f85 --- /dev/null +++ b/.taskless/rules/vale/no-em-dashes/.tests/pass/README.md @@ -0,0 +1,6 @@ +# Setup + +The installer writes two files: the config and the manifest. + +A hyphenated compound like `well-formed` is fine, and so is a range +written as 3-5 items. diff --git a/.taskless/rules/vale/no-em-dashes/.vale.ini b/.taskless/rules/vale/no-em-dashes/.vale.ini new file mode 100644 index 00000000..12e5b2b7 --- /dev/null +++ b/.taskless/rules/vale/no-em-dashes/.vale.ini @@ -0,0 +1,24 @@ +# Scoped to READMEs, where the repository is already clean. Broadening this +# to source comments and openspec is a separate, much larger decision. +[**/README.md] +tskl) rule = no-em-dashes +BasedOnStyles = +no-em-dashes.no-em-dashes = YES + +# Agent-facing instructions and the house conventions. Read as often as the +# READMEs are, by both people and agents, and small enough to keep conforming. +[CLAUDE.md] +tskl) rule = no-em-dashes +BasedOnStyles = +no-em-dashes.no-em-dashes = YES + +[.conventions/*.md] +tskl) rule = no-em-dashes +BasedOnStyles = +no-em-dashes.no-em-dashes = YES + +# Test fixtures are inputs to the CLI's own suite, not documentation. +[**/test/fixtures/**/README.md] +tskl) rule = no-em-dashes +BasedOnStyles = +no-em-dashes.no-em-dashes = NO diff --git a/.taskless/rules/vale/no-em-dashes/no-em-dashes.yml b/.taskless/rules/vale/no-em-dashes/no-em-dashes.yml new file mode 100644 index 00000000..7fb3f792 --- /dev/null +++ b/.taskless/rules/vale/no-em-dashes/no-em-dashes.yml @@ -0,0 +1,7 @@ +extends: existence +message: "Don't use em dashes. Use a period, comma, colon, or parentheses." +level: error +nonword: true +tokens: + - '—' + - '–' diff --git a/.taskless/rules/vale/no-hedging/.tests/fail/README.md b/.taskless/rules/vale/no-hedging/.tests/fail/README.md new file mode 100644 index 00000000..333938bd --- /dev/null +++ b/.taskless/rules/vale/no-hedging/.tests/fail/README.md @@ -0,0 +1,7 @@ +# Setup + +Simply run the installer and you are done. + +The remaining configuration is obviously a matter of taste. + +Of course, the token has to be exported first. diff --git a/.taskless/rules/vale/no-hedging/.tests/pass/README.md b/.taskless/rules/vale/no-hedging/.tests/pass/README.md new file mode 100644 index 00000000..4bc1996c --- /dev/null +++ b/.taskless/rules/vale/no-hedging/.tests/pass/README.md @@ -0,0 +1,8 @@ +# Setup + +Run the installer, then export the token before the first check. + +The remaining configuration is a matter of taste; the defaults are listed +below so you can see what changes. + +A variable named `obviously_stale` is an identifier, not prose. diff --git a/.taskless/rules/vale/no-hedging/.vale.ini b/.taskless/rules/vale/no-hedging/.vale.ini new file mode 100644 index 00000000..181cd731 --- /dev/null +++ b/.taskless/rules/vale/no-hedging/.vale.ini @@ -0,0 +1,22 @@ +# Prose docs a reader outside the team will hit. Code spans and fenced +# blocks are not prose, so this rule never sees a command or an identifier. +[**/README.md] +tskl) rule = no-hedging +BasedOnStyles = +no-hedging.no-hedging = YES + +[CLAUDE.md] +tskl) rule = no-hedging +BasedOnStyles = +no-hedging.no-hedging = YES + +[.conventions/*.md] +tskl) rule = no-hedging +BasedOnStyles = +no-hedging.no-hedging = YES + +# Test fixtures are inputs to the CLI's own suite, not documentation. +[**/test/fixtures/**/README.md] +tskl) rule = no-hedging +BasedOnStyles = +no-hedging.no-hedging = NO diff --git a/.taskless/rules/vale/no-hedging/no-hedging.yml b/.taskless/rules/vale/no-hedging/no-hedging.yml new file mode 100644 index 00000000..7c6c208c --- /dev/null +++ b/.taskless/rules/vale/no-hedging/no-hedging.yml @@ -0,0 +1,10 @@ +extends: existence +message: "Avoid '%s' — it hides the step the reader is stuck on" +level: warning +ignorecase: true +tokens: + - simply + - obviously + - of course + - trivially + - it should be clear diff --git a/.taskless/sgconfig.yml b/.taskless/sgconfig.yml deleted file mode 100644 index 2dd8e538..00000000 --- a/.taskless/sgconfig.yml +++ /dev/null @@ -1,2 +0,0 @@ -ruleDirs: - - rules diff --git a/.taskless/taskless.json b/.taskless/taskless.json index 01bce72b..d0d82140 100644 --- a/.taskless/taskless.json +++ b/.taskless/taskless.json @@ -1,13 +1,27 @@ { - "version": 2, + "version": 5, "install": { "targets": { + ".taskless": { + "skills": [ + "taskless" + ], + "commands": [ + "tskl.md" + ], + "mode": "canonical" + }, ".claude": { - "skills": ["taskless"], - "commands": ["tskl.md"] + "skills": [ + "taskless" + ], + "commands": [ + "tskl.md" + ], + "mode": "reference" } }, - "installedAt": "2026-05-11T16:38:34.273Z", - "cliVersion": "0.6.0" + "cliVersion": "0.11.0-20260824213902xf26a7b0", + "onboarded": true } } diff --git a/CLAUDE.md b/CLAUDE.md index 4240e622..f4688844 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ When running OpenSpec commands in this repo, use `pnpm openspec` instead of a ba git config --get-all remote.origin.fetch # must be +refs/heads/*:refs/remotes/origin/* ``` - If either is wrong, repair it once — both are local settings, nothing is committed: + If either is wrong, repair it once. Both are local settings, nothing is committed: ```bash git fetch --unshallow @@ -40,7 +40,7 @@ When running OpenSpec commands in this repo, use `pnpm openspec` instead of a ba git fetch origin ``` - Until then: `--force-with-lease` fails with `stale info` on every branch (there is no remote-tracking ref to lease against, so people fall back to a bare `--force`), `git push -u` cannot store an upstream, `gh pr create` needs an explicit `--head `, and `git branch -r` shows only `main`. The dangerous one is quieter — `git rebase main` is only correct while the merge base sits inside the shallow window, so as `main` advances a rebase can reconstruct the wrong base without saying so. + Until then: `--force-with-lease` fails with `stale info` on every branch (there is no remote-tracking ref to lease against, so people fall back to a bare `--force`), `git push -u` cannot store an upstream, `gh pr create` needs an explicit `--head `, and `git branch -r` shows only `main`. The dangerous one is quieter. `git rebase main` is only correct while the merge base sits inside the shallow window, so as `main` advances a rebase can reconstruct the wrong base without saying so. ## PR Issue References @@ -56,7 +56,7 @@ Reference issues as a **trailing line at the bottom of the PR body**, not inline - A bare `-NNN` resolves without a URL for **any** Linear team, not just `TSKL-`. `TSKL-` is Product and `OSS-` is the open-source team; verified with `OSS-23`, which the integration linked and moved to In Review on PR creation. - `Fixes` for the issue this PR resolves; `Refs` for a parent or related issue that stays open. -- Mentioning an issue in prose (`Found while investigating TSKL-5678.`) is **not** a reference — a PR can cite an issue mid-body with no trailing directive at all. +- Mentioning an issue in prose (`Found while investigating TSKL-5678.`) is **not** a reference: a PR can cite an issue mid-body with no trailing directive at all. - Only use a reference you can verify from user input, the branch name, commits, PR discussion, or tracker output. Never invent an issue number. ### Editing an existing PR @@ -80,8 +80,8 @@ Both flags can be passed in one call. See also **Stacked PRs → Other gotchas** The two rules that cause the most damage when missed: -- **A worktree gets its own empty `node_modules`.** `git worktree add` is not finished until `pnpm install` has run inside it. Without that, `git commit` fails in `lint-staged` (no `prettier`/`eslint`), and every `pnpm` script fails. A missing `prettier` here once cost an agent an hour of dead-end workarounds. There is no `pnpm worktree` command — `git worktree` is the tool. -- **NEVER point an agent at the main repo path** (e.g. `/Users//code/taskless/skills`). It will `cd` there and run git commands and edits in the **main** checkout, defeating isolation — it can create and check out a branch in your working tree, silently switching your session off its own branch. Tell the agent to work in **its assigned worktree** (`$PWD`) and pass only relative paths plus GitHub identifiers (`owner/repo`). +- **A worktree gets its own empty `node_modules`.** `git worktree add` is not finished until `pnpm install` has run inside it. Without that, `git commit` fails in `lint-staged` (no `prettier`/`eslint`), and every `pnpm` script fails. A missing `prettier` here once cost an agent an hour of dead-end workarounds. There is no `pnpm worktree` command; `git worktree` is the tool. +- **NEVER point an agent at the main repo path** (e.g. `/Users//code/taskless/skills`). It will `cd` there and run git commands and edits in the **main** checkout, defeating isolation. It can create and check out a branch in your working tree, silently switching your session off its own branch. Tell the agent to work in **its assigned worktree** (`$PWD`) and pass only relative paths plus GitHub identifiers (`owner/repo`). ## Stacked PRs @@ -91,23 +91,23 @@ When PRs stack, the **stack-breadcrumb workflow** (`.github/workflows/stack-brea The proposal states which of these the change is, and why. Decide it while writing the proposal, not when the diff has already grown too big to review. -| Shape | When | How it lands | -| ---------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Single PR** | The whole change fits one reviewable diff. | Spec, implementation, and the archive land together. | -| **Stacked, merging forward** | Each unit is independently safe in production. | Each PR merges to `main` in turn; the last one archives the change. | -| **Stacked, merging down** | The units are only correct together — an intermediate state would ship a broken or half-migrated product. | Merge each PR **down** into its parent from the tip, then one protected merge of the bottom branch to `main`. The change reaches `main` atomically. | +| Shape | When | How it lands | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Single PR** | The whole change fits one reviewable diff. | Spec, implementation, and the archive land together. | +| **Stacked, merging forward** | Each unit is independently safe in production. | Each PR merges to `main` in turn; the last one archives the change. | +| **Stacked, merging down** | The units are only correct together, and an intermediate state would ship a broken or half-migrated product. | Merge each PR **down** into its parent from the tip, then one protected merge of the bottom branch to `main`. The change reaches `main` atomically. | -**Prefer stacking, and aim to keep an individual diff under ~300 lines.** A 900-line PR does not get reviewed, it gets approved. Tests count toward the total but never split from the code they cover — if a unit is oversized because of its tests, that is usually a sign the unit itself should be smaller. +**Prefer stacking, and aim to keep an individual diff under ~300 lines.** A 900-line PR does not get reviewed, it gets approved. Tests count toward the total but never split from the code they cover. If a unit is oversized because of its tests, that is usually a sign the unit itself should be smaller. -The deciding question between forward and down is only this: **can each unit reach production on its own without breaking anything?** If landing unit 1 alone would leave `check` broken, tests failing, or a migration half-applied, the answer is no and the stack merges down. Do not assume forward because it is tidier — verify it, since "each unit is safe" is a claim about behavior, not intent. +The deciding question between forward and down is only this: **can each unit reach production on its own without breaking anything?** If landing unit 1 alone would leave `check` broken, tests failing, or a migration half-applied, the answer is no and the stack merges down. Do not assume forward because it is tidier. Verify it, since "each unit is safe" is a claim about behavior, not intent. -Note how this interacts with archiving: a change is archived exactly once, on whichever PR is the tip. No PR check asks about that — an unarchived change directory is the normal state of a pull request, so a PR-time gate can only guess at stack position, and it guessed wrong often enough to be ignored. The only check is on `main` (a step in `validate.yml`, push events only), which goes red while `main` carries an unarchived change directory. A stack that merges **down** keeps `main` clean throughout; a stack that merges **forward** leaves `main` red until its final slice archives the change. Nothing is blocked by that red — branch protection reads each PR's own `Validate` — but it is a standing reminder that the stack is unfinished. +Note how this interacts with archiving: a change is archived exactly once, on whichever PR is the tip. No PR check asks about that. An unarchived change directory is the normal state of a pull request, so a PR-time gate can only guess at stack position, and it guessed wrong often enough to be ignored. The only check is on `main` (a step in `validate.yml`, push events only), which goes red while `main` carries an unarchived change directory. A stack that merges **down** keeps `main` clean throughout; a stack that merges **forward** leaves `main` red until its final slice archives the change. Nothing is blocked by that red (branch protection reads each PR's own `Validate`), but it is a standing reminder that the stack is unfinished. ### One changeset, at the bottom of the stack, grown as the stack grows -`changeset.yml` looks for a `.changeset/*.md` added or modified **anywhere between `main` and the PR's head** — the whole stack, since a child branch contains its ancestors' commits. It **warns and never fails**: a missing changeset is a judgement call about whether the change ships a release note, and the workflow is not in a position to make it. +`changeset.yml` looks for a `.changeset/*.md` added or modified **anywhere between `main` and the PR's head**, meaning the whole stack, since a child branch contains its ancestors' commits. It **warns and never fails**: a missing changeset is a judgement call about whether the change ships a release note, and the workflow is not in a position to make it. -That is a deliberate retreat from a gate. A per-PR requirement had to reason about stack position to tell a real omission from a file that simply lives further down, and the `skip-changeset` label ended up being applied to silence a red check rather than to record "this ships no release note." The label survives, but it now suppresses a warning, so it can no longer be used to force a merge through. +That is a deliberate retreat from a gate. A per-PR requirement had to reason about stack position to tell a real omission from a file that merely lives further down, and the `skip-changeset` label ended up being applied to silence a red check rather than to record "this ships no release note." The label survives, but it now suppresses a warning, so it can no longer be used to force a merge through. The placement rules are unchanged, because they are about review quality rather than about passing a check: @@ -116,14 +116,14 @@ The placement rules are unchanged, because they are about review quality rather ### `branches:` filters do not tell you where a workflow runs -**`branches: [main]` does not reliably mean either "only the PR whose base is `main`" or "every PR in the stack."** 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 a filtered workflow runs on mid-stack PRs — observed on #73, #80, and #81, all with `openspec/partition-engine-*` bases. +**`branches: [main]` does not reliably mean either "only the PR whose base is `main`" or "every PR in the stack."** 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 a filtered workflow runs on mid-stack PRs. Observed on #73, #80, and #81, all with `openspec/partition-engine-*` bases. -**Do not depend on that resolution. It is undocumented and it stops without warning.** 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. Depth correlates (#102 is six hops from `main`, #103 seven) but nothing confirms a cap, and it was not a date cutoff: #102 kept getting runs after #103 had already stopped. A filter that works for six PRs and quietly fails on the seventh is worse than one that never worked, because nobody re-checks it. +**Do not depend on that resolution. It is undocumented and it stops without warning.** 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. Depth correlates (#102 is six hops from `main`, #103 seven) but nothing confirms a cap, and it was not a date cutoff: #102 kept getting runs after #103 had already stopped. A filter that works for six PRs and quietly fails on the seventh is worse than one that never worked, because nobody re-checks it. Two rules follow, and they pull in opposite directions: -- **A workflow that must run everywhere carries no `branches:` filter at all.** Lint, typecheck, and tests have no interest in where a PR eventually merges. `validate.yml`, `changeset.yml`, and `stack-breadcrumb.yml` all carry no filter, which is why they kept running on #103. If you add such a workflow, also name `ready_for_review` in `types:` — it is not in the default set (`opened`/`synchronize`/`reopened`), so without it a draft marked ready gets no fresh run until someone happens to push again. -- **A workflow whose correctness depends on "is this the PR that merges to `main`" must determine that itself** — from the base ref, or by resolving stack position — and cannot lean on the `on:` filter to scope it. Better still, ask a question that does not depend on stack position at all: `changeset.yml` diffs against `main` rather than against its base, and the archive check moved off pull requests entirely. +- **A workflow that must run everywhere carries no `branches:` filter at all.** Lint, typecheck, and tests have no interest in where a PR eventually merges. `validate.yml`, `changeset.yml`, and `stack-breadcrumb.yml` all carry no filter, which is why they kept running on #103. If you add such a workflow, also name `ready_for_review` in `types:`. It is not in the default set (`opened`/`synchronize`/`reopened`), so without it a draft marked ready gets no fresh run until someone happens to push again. +- **A workflow whose correctness depends on "is this the PR that merges to `main`" must determine that itself**, from the base ref or by resolving stack position, and cannot lean on the `on:` filter to scope it. Better still, ask a question that does not depend on stack position at all: `changeset.yml` diffs against `main` rather than against its base, and the archive check moved off pull requests entirely. The shared point: the `on:` filter is not a reliable answer to "where does this PR land." Let the workflow run, and decide inside it. @@ -131,11 +131,11 @@ The shared point: the `on:` filter is not a reliable answer to "where does this Put the changeset at the base and every branch above inherits it, since a child contains its ancestors' commits. -**Write it on the base branch before you cut the children.** Inheritance only runs forward in time: a child branched before the file existed does not carry it, and "grown as the stack grows" has nothing to grow. What makes this easy to miss is that the natural moment to write a release note is when you finish a unit — which is exactly the moment you are standing on a child branch, several branches above the base. A changeset stranded on the tip still reaches `main` when a stack merges down, but on a forward-merging stack it means every PR below it lands with no release note. +**Write it on the base branch before you cut the children.** Inheritance only runs forward in time: a child branched before the file existed does not carry it, and "grown as the stack grows" has nothing to grow. What makes this easy to miss is that the natural moment to write a release note is when you finish a unit, which is exactly the moment you are standing on a child branch, several branches above the base. A changeset stranded on the tip still reaches `main` when a stack merges down, but on a forward-merging stack it means every PR below it lands with no release note. -**Grow it incrementally when the stack merges forward.** Each PR extends the changeset with its own scope rather than the base describing the whole future change up front. A reviewer reading the changeset then sees only what has actually landed, and is not asked to evaluate a release note that promises more than the diff in front of them. When you extend it, edit the same file on the branch you are working on — never add a second changeset per PR, or one change becomes several release notes for what merges to `main` exactly once. +**Grow it incrementally when the stack merges forward.** Each PR extends the changeset with its own scope rather than the base describing the whole future change up front. A reviewer reading the changeset then sees only what has actually landed, and is not asked to evaluate a release note that promises more than the diff in front of them. When you extend it, edit the same file on the branch you are working on. Never add a second changeset per PR, or one change becomes several release notes for what merges to `main` exactly once. -**When the stack merges down, that reasoning does not apply.** Nothing reaches `main` until everything does — a single protected merge carries the whole stack — so a changeset describing the complete change is accurate at the only moment it is ever read, and no reviewer is asked to approve more than what lands. Growing it per unit is still friendlier to review, but there it is a preference, not a correctness constraint. +**When the stack merges down, that reasoning does not apply.** Nothing reaches `main` until everything does (a single protected merge carries the whole stack), so a changeset describing the complete change is accurate at the only moment it is ever read, and no reviewer is asked to approve more than what lands. Growing it per unit is still friendlier to review, but there it is a preference, not a correctness constraint. In both shapes the file belongs **on the bottom branch**. Nothing enforces that any more, so it is on you: a forward-merging stack publishes from `main` as each slice lands, and only a changeset that is already there gets read. @@ -147,7 +147,7 @@ Merge each PR **down** into its parent's branch, from the tip to the bottom: - Bring the bottom branch up to date with `main`, let `Validate` pass, then do the **single** protected merge to `main`. - Result: one CI cycle instead of N, and every PR gets a real **Merged** badge (not "closed/absorbed"). -**Merge the down-merges one at a time, not in a loop.** Merging a child immediately invalidates the parent PR's mergeability until GitHub recomputes — `gh pr merge` fails with "Pull Request is not mergeable", and the API reports `rebaseable: null`. In a tight loop this makes merges land **out of order**, which strands the tip's commits part-way down the stack (e.g. `skill`/`eval` never propagate past `help`). Merge each PR, wait for the next to report a boolean `rebaseable`, then continue. +**Merge the down-merges one at a time, not in a loop.** Merging a child immediately invalidates the parent PR's mergeability until GitHub recomputes: `gh pr merge` fails with "Pull Request is not mergeable", and the API reports `rebaseable: null`. In a tight loop this makes merges land **out of order**, which strands the tip's commits part-way down the stack (e.g. `skill`/`eval` never propagate past `help`). Merge each PR, wait for the next to report a boolean `rebaseable`, then continue. **Verify by content, not by ancestry.** Rebase-and-merge replays commits under new SHAs, so the tip's original commits are never ancestors of the branch that absorbed them, and the obvious check reports a false `STRANDED`: @@ -159,7 +159,7 @@ git merge-base --is-ancestor origin/ origin/ git diff --stat origin/ origin/ # empty = fully absorbed ``` -An empty diff with differing SHAs is the *expected* healthy state after a rebase merge, not evidence of a problem. If the diff is genuinely non-empty, reconcile from the tip — a tip branch contains the whole stack — then re-check the diff and push. +An empty diff with differing SHAs is the _expected_ healthy state after a rebase merge, not evidence of a problem. If the diff is genuinely non-empty, reconcile from the tip (a tip branch contains the whole stack), then re-check the diff and push. ### Never `--delete-branch` mid-stack @@ -167,7 +167,7 @@ An empty diff with differing SHAs is the *expected* healthy state after a rebase ### Rebase is the only merge method, and a stack pays for it -`main` keeps a linear history, so the repository allows **rebase-and-merge only** — squash and merge-commit are both disabled. Confirm rather than assume, since this changed: +`main` keeps a linear history, so the repository allows **rebase-and-merge only**; squash and merge-commit are both disabled. Confirm rather than assume, since this changed: ```bash gh api repos/{owner}/{repo} --jq '"squash=\(.allow_squash_merge) merge=\(.allow_merge_commit) rebase=\(.allow_rebase_merge)"' @@ -176,7 +176,7 @@ gh api repos/{owner}/{repo} --jq '"squash=\(.allow_squash_merge) merge=\(.allow_ `gh pr merge --merge` and `--squash` both fail. Use `gh pr merge --rebase`. -**This is the expensive case for a stack, and there is no cheaper option available.** Rebase-and-merge replays the branch onto `main` as *new commits with new SHAs*. Every child then contains the pre-rebase versions of its ancestors' commits, so the child is not merely behind — its history diverged. After each merge you must rebase the next branch onto the updated `main` and force-push it. The old guidance to prefer merge-commits so children stay clean no longer applies; that door is closed. +**This is the expensive case for a stack, and there is no cheaper option available.** Rebase-and-merge replays the branch onto `main` as _new commits with new SHAs_. Every child then contains the pre-rebase versions of its ancestors' commits, so the child is not merely behind: its history diverged. After each merge you must rebase the next branch onto the updated `main` and force-push it. The old guidance to prefer merge-commits so children stay clean no longer applies; that door is closed. Practically, landing a stack now looks like: @@ -190,8 +190,8 @@ git push origin --force-with-lease=:$(git rev-parse origin/)
:` fails with `stale info` when `` is not what the remote currently holds — which includes the case where *you* rebased the branch a moment ago and reached for its old tip. `$(git rev-parse origin/)` after a `git fetch` is the value that works. The failure looks like the shallow-clone symptom in the git section above and is not: check whether the SHA is simply out of date before concluding anything about the clone. +- **Right after a merge, `rebaseable` reads `null`** while GitHub recomputes. Poll until it is a boolean rather than treating `null` as "not mergeable". Reading it as a failure is what stranded commits mid-stack before. +- **Read the lease SHA from the remote, not from memory.** `--force-with-lease=:` fails with `stale info` when `` is not what the remote currently holds, which includes the case where _you_ rebased the branch a moment ago and reached for its old tip. `$(git rev-parse origin/)` after a `git fetch` is the value that works. The failure looks like the shallow-clone symptom in the git section above and is not: check whether the SHA is just out of date before concluding anything about the clone. ### Rebase-and-merge lands unsigned commits on `main` @@ -201,11 +201,11 @@ Commits are signed locally (`git commit -S`, mandatory above), but **GitHub rewr git log --format='%G? %h %s' -5 origin/main # N, N, N, … ``` -Nothing is wrong and nothing needs fixing on `main`. Know it so that `%G?` on a merged commit is not mistaken for a signing failure, and so a fresh commit reading `N` **before** it reaches `main` is recognised as the real problem it is — that one means `-S` was missed. +Nothing is wrong and nothing needs fixing on `main`. Know it so that `%G?` on a merged commit is not mistaken for a signing failure, and so a fresh commit reading `N` **before** it reaches `main` is recognised as the real problem it is: that one means `-S` was missed. ### Recovery if a child PR gets closed by base-branch deletion -This happens when the **parent** PR is merged with `--delete-branch`: deleting the parent's head branch (which is the child's base) closes the **child** PR. Two PRs are involved — the merged parent (``) and the closed child (``); `` is the deleted base, i.e. the parent's head branch. +This happens when the **parent** PR is merged with `--delete-branch`: deleting the parent's head branch (which is the child's base) closes the **child** PR. Two PRs are involved, the merged parent (``) and the closed child (``); `` is the deleted base, i.e. the parent's head branch. 1. Restore the deleted base branch from **GitHub's own copy of the parent's head**, `refs/pull//head`. GitHub keeps that ref after the branch is deleted and after the PR is merged, and it points at the pre-merge tip: @@ -220,9 +220,10 @@ This happens when the **parent** PR is merged with `--delete-branch`: deleting t git rev-parse "$MERGE_SHA^2" # WRONG under rebase-and-merge ``` - `^2` needs the merge commit to *have* two parents, which is true only of a merge-commit merge. Rebase replays the branch as linear single-parent commits, so `^2` fails with "unknown revision" — and this repository is rebase-only, so it fails always. `refs/pull//head` is correct under every merge method, which is the better reason to prefer it. + `^2` needs the merge commit to _have_ two parents, which is true only of a merge-commit merge. Rebase replays the branch as linear single-parent commits, so `^2` fails with "unknown revision", and this repository is rebase-only, so it fails always. `refs/pull//head` is correct under every merge method, which is the better reason to prefer it. + + Take the ref from ``, the PR that actually merged, not from the closed child, whose head is a different branch. - Take the ref from `` — the PR that actually merged — not from the closed child, whose head is a different branch. 2. Reopen the child via **REST** (GraphQL `gh pr reopen` fails on the Projects-classic deprecation): `gh api --method PATCH repos///pulls/ -f state=open` 3. Retarget it: `gh pr edit --base main` (only works once it's open). diff --git a/README.md b/README.md index 11e1df90..a09782c1 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ skills/ commands/ tskl/tskl.md # Single /tskl router command packages/ - cli/ # @taskless/cli — recipes live in cli/src/agent/ + cli/ # @taskless/cli, recipes live in cli/src/agent/ scripts/ sync-skill-versions.ts # Syncs metadata.version to CLI version .claude-plugin/ # Claude Code Plugin Marketplace manifest @@ -35,7 +35,6 @@ Available `taskless agent` topics: `route`, `create-sg-rule`, `create-vale-rule` The `@taskless/cli` package provides a CLI agent for Taskless workflows. It's recommended to always call the `latest` tag unless you know you need a specific version: ```bash -pnpm dlx @taskless/cli@latest info npx @taskless/cli@latest info ``` @@ -46,7 +45,7 @@ content it installs. Three build targets pick that string, all driven by the `TASKLESS_BUILD_TARGET` env var via Vite `define` (same source files, no edits): Each target also emits to its own directory so the three never overwrite one -another — prod → `dist/`, dev → `dist-dev/`, self → `dist-self/` (all +another. Prod → `dist/`, dev → `dist-dev/`, self → `dist-self/` (all gitignored): | Command | Output dir | Baked invocation | Use for | @@ -56,14 +55,26 @@ gitignored): | `pnpm build:self` | `dist-self/` | `node packages/cli/dist-self/index.js` | Dogfooding **in this repo** (path is repo-root-relative; run the CLI from the root). | `pnpm build:self` builds the CLI with the relative invocation and then runs -`taskless init --no-interactive` to install into this repo — so `.claude` gets +`taskless init --no-interactive` to install into this repo, so `.claude` gets real reference stubs that delegate to the canonical `.taskless/` content, exactly like any other install. (This replaces the former raw-symlink `link-skills` step, so local dogfooding always matches a true install.) -> The `dev`/`self` invocations are local paths and must never be published — +> The `dev`/`self` invocations are local paths and must never be published: > only `pnpm build` (or `pnpm package`) produces a release artifact. +### Running the local build + +The root `package.json` defines a `cli` script pointing at +`./packages/cli/dist/index.js`. That script runs the CLI built from this working +tree instead of a published release, which is what `CLAUDE.md` points +contributors and agents at while they are working in this repo. Nothing rebuilds +`dist/` for you, so run `pnpm build` first when you want current behavior. + +This section names the script rather than spelling out its shell invocation, because +the `docs-npx-cli` rule holds every command in a README to the published +`npx @taskless/cli` form for readers who do not have this repo checked out. + ## Releasing taskless/cli Releases use [Changesets](https://github.com/changesets/changesets) with Turborepo for orchestration. @@ -79,7 +90,7 @@ pnpm test # Run all tests, confirm no errors git add -A # Stage all changes git commit -m "chore: Releases vx.y.z" # Commit with new version number git push origin main # Push the release commit -pnpm release # Dry run — prints publish command when ready +pnpm release # Dry run, prints publish command when ready pnpm release:production # Publish to npm (prompts for 2FA OTP) ``` @@ -109,7 +120,7 @@ npx @taskless/cli-nightly@latest --version # or: npm i -g @taskless/cli-nightl when both are installed globally.** That is not a supported configuration: a nightly is a drop-in for the release it anticipates, not a companion to it. Use one or the other globally, or install the nightly into a project. -- Versions look like `0.11.0-20260818123456x05b3c88` — the release the nightly +- Versions look like `0.11.0-20260818123456x05b3c88`. The release the nightly anticipates, the UTC build time, and the commit it was built from. Every one of them is a prerelease, and the newest always carries the `latest` tag, so installing with no version gives you the most recent nightly. @@ -129,6 +140,6 @@ In v0.7+, new agent-facing instructions are added as **recipes**, not skills. To ### Distribution channels -- **`taskless init`** — CLI installs the consolidated skill to `.claude/skills/taskless/` and the command to `.claude/commands/tskl/` -- **Claude Code Plugin Marketplace** — `.claude-plugin/marketplace.json` and `plugin.json` -- **Vercel Skills CLI** — `npx skills add` discovers skills from `skills/` directory +- **`taskless init`**: CLI installs the consolidated skill to `.claude/skills/taskless/` and the command to `.claude/commands/tskl/` +- **Claude Code Plugin Marketplace**: `.claude-plugin/marketplace.json` and `plugin.json` +- **Vercel Skills CLI**: `npx skills add` discovers skills from `skills/` directory diff --git a/eslint.config.js b/eslint.config.js index 113646db..2444286a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -28,6 +28,12 @@ export default tseslint.config( // Zero-dependency CommonJS workflow scripts (covered by their own // node:test suite); the app's TS/ESM-oriented rules don't apply. ".github/scripts/", + // Taskless rule fixtures. A rule's `.tests/` holds inputs written to be + // flagged, and a rule about source comments needs `.ts` fixtures + // specifically — Vale picks its comments-only tier by extension. They are + // not part of any tsconfig, so the type-aware rules fail to parse them. + // `taskless verify` and `taskless test` are what keep them honest. + ".taskless/", // The demo project. Its source is deliberately wrong — `example.cjs` // calls `eval` so a rule has something to find — and its fixtures are // prose written to be flagged. Linting it fails on content nobody wrote diff --git a/packages/cli/README.md b/packages/cli/README.md index 357e95e6..1ed8935b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -11,11 +11,7 @@ CLI companion for [Taskless](https://taskless.io). Designed to work with agent s ## Install ```bash -# npm npx @taskless/cli - -# pnpm -pnpm dlx @taskless/cli ``` Run with no arguments in a terminal to launch the installer, which detects the @@ -26,7 +22,7 @@ each of them. For scripted installs, skip the prompts: npx @taskless/cli init --no-interactive ``` -New to Taskless? Run `npx @taskless/cli onboard` after installing — it walks your +New to Taskless? Run `npx @taskless/cli onboard` after installing. It walks your agent through your codebase and suggests a starter set of rules. ## How to Use via Agents @@ -40,7 +36,7 @@ asked for, then follows it. /tskl add taskless to CI ``` -Plain language works too — "write a taskless rule for X", "run taskless check", +Plain language works too: "write a taskless rule for X", "run taskless check", "taskless login" all engage the skill. You rarely need to run the CLI yourself. To see what the agent sees, run `npx @taskless/cli agent` for the topic index, or @@ -60,7 +56,7 @@ npx @taskless/cli check --json # machine-readable Paths that no longer exist are dropped silently, so raw `git diff` output can be piped in without pre-filtering. Static rules need no login and make no network -calls, so CI needs no secrets. Runtime rules — which execute code — only run once +calls, so CI needs no secrets. Runtime rules (which execute code) only run once the server has verified their signature; otherwise they are reported as skipped and never change the exit code. @@ -70,27 +66,27 @@ system you already use rather than replacing it. ## Why Teams Choose Taskless - **Constraints, not suggestions.** Rules are real files in your repo, enforced - by ast-grep, Vale, and runtime checks — the same result every run, for every + by ast-grep, Vale, and runtime checks: the same result every run, for every agent and every human. - **The same rules in the editor and in CI.** One command, one exit code. - **Works with the agent you already have.** One skill installs into Claude Code, - Cursor, and OpenCode — plus the `/tskl` command wherever the tool supports slash - commands — with a plain `.agents/` fallback when none is detected. + Cursor, and OpenCode, plus the `/tskl` command wherever the tool supports slash + commands, with a plain `.agents/` fallback when none is detected. - **Nothing to run locally.** No daemon, no install step in CI, no auth for the checks that matter most. ## Docs -- [docs.taskless.io](https://docs.taskless.io) — guides and reference -- [taskless.io](https://taskless.io) — the product -- [github.com/taskless/cli](https://github.com/taskless/cli) — source and issues +- [docs.taskless.io](https://docs.taskless.io): guides and reference +- [taskless.io](https://taskless.io): the product +- [github.com/taskless/cli](https://github.com/taskless/cli): source and issues
Other ### Telemetry -The CLI reports anonymous usage — which command ran, whether it succeeded, how +The CLI reports anonymous usage (which command ran, whether it succeeded, how long it took, and counts of findings. It never sends rule content, prompts, or matched source. Disable it by setting either environment variable: diff --git a/packages/vale-darwin-arm64/README.md b/packages/vale-darwin-arm64/README.md index d8ce870f..13b3e1a0 100644 --- a/packages/vale-darwin-arm64/README.md +++ b/packages/vale-darwin-arm64/README.md @@ -10,7 +10,7 @@ install time. Nothing else: no `bin` entry, no JavaScript, and no lifecycle script. A consumer locates the executable by resolving this package and running the file by path, so the binary is usable even where the consuming package manager refuses to run -dependency build scripts — which pnpm 10 does by default. +dependency build scripts, which pnpm 10 does by default. `os` and `cpu` are declared, so this package installs only on a matching host and is skipped everywhere else without failing the install. diff --git a/packages/vale-darwin-x64/README.md b/packages/vale-darwin-x64/README.md index b9f796f1..31f9f9fb 100644 --- a/packages/vale-darwin-x64/README.md +++ b/packages/vale-darwin-x64/README.md @@ -10,7 +10,7 @@ install time. Nothing else: no `bin` entry, no JavaScript, and no lifecycle script. A consumer locates the executable by resolving this package and running the file by path, so the binary is usable even where the consuming package manager refuses to run -dependency build scripts — which pnpm 10 does by default. +dependency build scripts, which pnpm 10 does by default. `os` and `cpu` are declared, so this package installs only on a matching host and is skipped everywhere else without failing the install. diff --git a/packages/vale-linux-arm64/README.md b/packages/vale-linux-arm64/README.md index e31a3973..999a3687 100644 --- a/packages/vale-linux-arm64/README.md +++ b/packages/vale-linux-arm64/README.md @@ -10,7 +10,7 @@ install time. Nothing else: no `bin` entry, no JavaScript, and no lifecycle script. A consumer locates the executable by resolving this package and running the file by path, so the binary is usable even where the consuming package manager refuses to run -dependency build scripts — which pnpm 10 does by default. +dependency build scripts, which pnpm 10 does by default. `os` and `cpu` are declared, so this package installs only on a matching host and is skipped everywhere else without failing the install. @@ -43,9 +43,9 @@ the same digest upstream publishes in `vale__checksums.txt`. ## glibc, and why there is no musl package -Vale's Linux build is dynamically linked against glibc — `ELF 64-bit LSB +Vale's Linux build is dynamically linked against glibc (`ELF 64-bit LSB executable, ARM aarch64, dynamically linked, interpreter -/lib/ld-linux-aarch64.so.1, for GNU/Linux 3.7.0` — so it is not a static Go +/lib/ld-linux-aarch64.so.1, for GNU/Linux 3.7.0`), so it is not a static Go binary and it does not run on musl-based distributions such as Alpine. Upstream publishes no musl asset, so there is nothing to package for those hosts; they fall back to a `vale` found on `PATH`. diff --git a/packages/vale-linux-x64/README.md b/packages/vale-linux-x64/README.md index 9e57f38f..20f59007 100644 --- a/packages/vale-linux-x64/README.md +++ b/packages/vale-linux-x64/README.md @@ -10,7 +10,7 @@ install time. Nothing else: no `bin` entry, no JavaScript, and no lifecycle script. A consumer locates the executable by resolving this package and running the file by path, so the binary is usable even where the consuming package manager refuses to run -dependency build scripts — which pnpm 10 does by default. +dependency build scripts, which pnpm 10 does by default. `os` and `cpu` are declared, so this package installs only on a matching host and is skipped everywhere else without failing the install. @@ -43,9 +43,9 @@ the same digest upstream publishes in `vale__checksums.txt`. ## glibc, and why there is no musl package -Vale's Linux build is dynamically linked against glibc — `ELF 64-bit LSB +Vale's Linux build is dynamically linked against glibc (`ELF 64-bit LSB executable, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for -GNU/Linux 3.2.0` — so it is not a static Go binary and it does not run on +GNU/Linux 3.2.0`), so it is not a static Go binary and it does not run on musl-based distributions such as Alpine. Upstream publishes no musl asset, so there is nothing to package for those hosts; they fall back to a `vale` found on `PATH`. diff --git a/packages/vale-win32-arm64/README.md b/packages/vale-win32-arm64/README.md index d45c5bfa..7737b72e 100644 --- a/packages/vale-win32-arm64/README.md +++ b/packages/vale-win32-arm64/README.md @@ -10,7 +10,7 @@ install time. Nothing else: no `bin` entry, no JavaScript, and no lifecycle script. A consumer locates the executable by resolving this package and running the file by path, so the binary is usable even where the consuming package manager refuses to run -dependency build scripts — which pnpm 10 does by default. +dependency build scripts, which pnpm 10 does by default. `os` and `cpu` are declared, so this package installs only on a matching host and is skipped everywhere else without failing the install. diff --git a/packages/vale-win32-x64/README.md b/packages/vale-win32-x64/README.md index 63d1e2a8..b9106068 100644 --- a/packages/vale-win32-x64/README.md +++ b/packages/vale-win32-x64/README.md @@ -10,7 +10,7 @@ install time. Nothing else: no `bin` entry, no JavaScript, and no lifecycle script. A consumer locates the executable by resolving this package and running the file by path, so the binary is usable even where the consuming package manager refuses to run -dependency build scripts — which pnpm 10 does by default. +dependency build scripts, which pnpm 10 does by default. `os` and `cpu` are declared, so this package installs only on a matching host and is skipped everywhere else without failing the install.