diff --git a/.changeset/onboard-decide-when-rules-run.md b/.changeset/onboard-decide-when-rules-run.md new file mode 100644 index 00000000..e66271e2 --- /dev/null +++ b/.changeset/onboard-decide-when-rules-run.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +Onboarding now settles when the new rules run before it asks to mark itself complete. After the rules are materialized, the onboard recipe tells the user which CI systems and commit-hook tools the repository has, offers to wire `check` into CI (`agent ci`) and into a pre-commit hook (the new `agent hooks` topic), and asks the consent-gated mark-complete question on its own, so a "yes" can no longer be read as an answer to both. `detect --json` gains two additive fields for this, `ci` and `hooks`, read from configuration at the repository root. diff --git a/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/.openspec.yaml b/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/.openspec.yaml new file mode 100644 index 00000000..e3966d7a --- /dev/null +++ b/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-05 diff --git a/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/design.md b/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/design.md new file mode 100644 index 00000000..de633230 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/design.md @@ -0,0 +1,50 @@ +## Context + +Onboarding ends with rules on disk. Whether those rules ever run depends on CI or a commit hook calling `check`, and the onboard recipe never raised either. The `ci` topic existed but nothing pointed to it from onboarding, and no topic covered a local hook. + +## Goals / Non-Goals + +**Goals:** + +- The onboard flow asks when the rules run before it asks about `--mark-complete`, and never puts the two questions in one message. +- The agent starts from facts the CLI measured (which CI systems and which hook tools are configured) rather than re-deriving them by listing files. +- A recipe for the pre-commit case that is right about `check`'s actual behavior. + +**Non-Goals:** + +- Installing a hook manager. The recipe wires `check` into the tool the repository already uses, and asks before introducing one. +- Telling whether an existing CI workflow already runs Taskless. That needs reading workflow contents for intent, which is the agent's job, not a deterministic signal. +- Detecting raw `.git/hooks/*` scripts or `core.hooksPath`. Neither is committed, so neither is shared configuration the scan can stand on. + +## Decisions + +### `detect` reports `ci` and `hooks` as `{ name, evidence }` lists + +Same shape as `linters`, so a consumer already handling one handles all three. `evidence` carries the path or the `package.json` marker that triggered the match, which keeps the "every signal is attributable" property `linters` has. + +**CI is matched at the scan root only.** CI systems read their config from the repository root, so a `.gitlab-ci.yml` three directories down is a fixture or a vendored project, not this repository's CI. The monorepo-wide walk is right for linter configs and wrong here. + +**Hook tools are matched at the scan root too**, for the same reason: git runs one set of hooks per repository, installed from the root. A root `package.json` dependency or config key counts; a sub-package's does not. + +lint-staged is reported beside the hook managers even though it is not one. It is what turns "run on commit" into "run on the staged files", which is the question the onboard step asks, and leaving it out would hide the most common way that question is already answered. + +Rejected: a single `automation` field mixing both. The onboard step offers CI and hooks as two separate choices, and the `ci` and `hooks` topics each read one list. + +### The hook runs the repository's pinned binary, spelled `` + +A hook should run the version the repository pins as a dev dependency, so the hook, CI and every developer agree. That is a different binary from `%(TASKLESS_CLI)s` (how this recipe was fetched) and from `%(PACKAGE_MANAGER_DLX)s` (a download-and-run launcher, which defeats the pin). The recipe names it with an agent-fill marker, ``, and lists once what it expands to per package manager. Because the marker is never followed by a literal subcommand, the cross-reference guard that rejects hand-written invocations has nothing to flag and needs no allowlist entry, unlike `ci.md`'s `pnpm taskless check`. + +Rejected: a new sprintf placeholder resolved at render time. The CLI cannot know the target repository's package manager at render time any better than the agent can by looking at the lockfile, and a value guessed at render time reads as measured. + +### `--no-stash` is presented with its cost + +`check` edits no tracked file (its working files go under `.taskless/.run/` and are removed when the run ends), so lint-staged's backup stash protects nothing for a `check`-only task, and that stash goes on the stack every worktree shares. But lint-staged documents that `--no-stash` also implies `--no-hide-partially-staged`, so a partially staged file is checked as it is in the working tree, not as it is staged. The recipe states both and leaves the trade to the user rather than recommending the flag unconditionally. + +### Step order in onboard + +The new step sits after materialization (there must be rules to run) and before the mark-complete question. The mark-complete question keeps its existing consent wording and gains one constraint: it is asked in a message that asks nothing else. + +## Risks / Trade-offs + +- [A hook tool's config syntax drifts] → The recipe gives the shape per tool and tells the agent to check the tool's own docs for anything beyond it, rather than encoding version-specific detail. +- [`check $FILES` word-splits paths with spaces] → Same limitation the `ci` recipe already carries. lint-staged and pre-commit hand the file list to the command themselves, so the shell-substitution forms (bare husky, simple-git-hooks) are the ones exposed, and the recipe says so. diff --git a/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/proposal.md b/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/proposal.md new file mode 100644 index 00000000..a650f38f --- /dev/null +++ b/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/proposal.md @@ -0,0 +1,38 @@ +## Why + +The onboard recipe goes straight from materializing rules to asking whether to mark onboarding complete. Nothing in between asks when the new rules will run, so a project can finish onboarding with rules that nothing ever runs. When the agent improvised the missing question, it put it in the same message as the consent-gated `--mark-complete` question, and the user's "yes" could be read as an answer to either (#441). + +## What Changes + +- The onboard recipe gains a step between materializing rules and marking complete: **decide when the rules run**. The agent reports what the repository already has for CI and for commit hooks, offers to wire `check` into CI (`agent ci`) and into a pre-commit hook (`agent hooks`), and does whichever the user picks. Only after that does it ask the `--mark-complete` question, in a message with no other question in it. +- The onboard recipe's See Also names `agent ci` and `agent hooks`. +- `detect --json` reports two more deterministic, on-disk signals beside `linters`: `ci`, the CI systems configured at the scan root (the same file table the `ci` recipe uses), and `hooks`, the tools that run commands at commit time (husky, lefthook, pre-commit, simple-git-hooks, lint-staged). The new fields are additive. Nothing existing changes shape. +- A new internal agent topic, `hooks`, covers running `check` on staged files before a commit, in whichever hook tool the repository already uses. It records what the agent in #441 had to work out alone: `check` takes paths and skips ones that don't exist, so a staged-file list can go straight in; `check` edits no tracked file, which is what makes lint-staged's `--no-stash` safe, and the recipe states what that flag gives up; a change under `.taskless/rules/` calls for `test` and a full `check`; and the pinned dev dependency is what keeps the hook, CI and every developer on one version. +- The `ci` recipe reads the CI systems from `detect --json` before falling back to its file table, and names `agent hooks` in See Also. +- The installed skill's topic table gains a row for `agent hooks`. + +## Capabilities + +### New Capabilities + +None. The `hooks` topic is registered under the existing `cli-agent` capability, the way `onboard` was. + +### Modified Capabilities + +- `cli-onboard`: the recipe requirement gains the "decide when the rules run" step and requires the mark-complete question to be asked on its own. +- `cli-detect`: the signal list grows from linters, languages and rule styles to also include CI systems and commit-hook tools, and the JSON-shape requirement names the new fields. +- `cli-agent`: a requirement registers the `hooks` topic. + +## Delivery shape + +**Single PR.** One recipe step, two additive `detect` fields, and one new recipe, all landing together with their tests and spec deltas, fit well inside one reviewable diff. They are also only coherent together: an onboard step that sends the agent to `agent hooks` before that topic exists would cite a topic that does not resolve, and `recipe-cross-references.test.ts` fails on exactly that. + +## Impact + +- `packages/cli/src/agent/onboard.md` (topic v4 → v5), `ci.md` (v3 → v4), `detect.md` (v1 → v2), new `hooks.md` (v1). +- `packages/cli/src/detect/scan.ts` and the `detect` output schema: new `ci` and `hooks` arrays. +- `packages/cli/src/prompts/index.ts`: `hooks` joins `INTERNAL_TOPICS`. +- `skills/taskless/SKILL.md`: new topic row. +- `check.md` (v6 → v7): See Also names `agent hooks`. +- Tests: `detect.test.ts` and `onboard.test.ts`. The recipe cross-reference guard needs no new allowlist entry, because the hook's invocation is written as a marker rather than a literal command. +- No API, auth, or network change. `detect` stays offline and deterministic. diff --git a/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/specs/cli-agent/spec.md b/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/specs/cli-agent/spec.md new file mode 100644 index 00000000..675f459d --- /dev/null +++ b/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/specs/cli-agent/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: hooks topic is registered + +An agent topic `hooks` SHALL be registered. The CLI SHALL embed `packages/cli/src/agent/hooks.md` at build time via the existing `import.meta.glob` mechanism, and `taskless agent hooks` SHALL print it. The topic SHALL be classified as internal to the CLI rather than exported from `@taskless/cli/prompts`, since it walks an agent through editing a developer's repository. + +The recipe SHALL describe running `check` on the staged files before a commit, in the hook tool the repository already uses, and SHALL NOT install a hook manager without asking the user first. It SHALL direct the agent to the `hooks` field of `taskless detect --json` for which tools the repository has. + +The recipe SHALL be accurate about `check`'s behavior where a hook depends on it: + +- `check` accepts file paths and skips paths that do not exist, so a staged-file list that includes deleted files can be passed directly, and a hook SHALL exit cleanly rather than run a full scan when the staged list is empty. +- `check` edits no tracked file. Where the recipe offers lint-staged's `--no-stash` on that basis, it SHALL also state that the flag makes lint-staged check a partially staged file as it is in the working tree rather than as it is staged. +- A staged change under `.taskless/rules/` SHALL be followed by `taskless test` and a full `check`, since a rule change can affect files that were not staged. + +The recipe SHALL have the hook run the repository's own pinned `@taskless/cli` dev dependency rather than a download-and-run launcher, so the hook, CI and every developer run one version. + +#### Scenario: The hooks topic returns the recipe + +- **WHEN** a user runs `taskless agent hooks` +- **THEN** the CLI SHALL print the contents of `hooks.md` to stdout +- **AND** SHALL exit with code 0 + +#### Scenario: The hooks topic is internal + +- **WHEN** the topic classification in `@taskless/cli/prompts` is read +- **THEN** `hooks` SHALL be listed among the internal topics +- **AND** SHALL NOT be an exported prompt topic + +#### Scenario: The recipe states the cost of --no-stash + +- **WHEN** `hooks.md` mentions lint-staged's `--no-stash` +- **THEN** it SHALL state that a partially staged file is then checked as it is in the working tree + +#### Scenario: A rule change triggers test and a full check + +- **WHEN** `hooks.md` is read +- **THEN** it SHALL instruct the agent to run `taskless test` and an unscoped `check` when a staged path is under `.taskless/rules/` diff --git a/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/specs/cli-detect/spec.md b/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/specs/cli-detect/spec.md new file mode 100644 index 00000000..e6654e5d --- /dev/null +++ b/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/specs/cli-detect/spec.md @@ -0,0 +1,121 @@ +## MODIFIED Requirements + +### Requirement: Detect scans deterministic repo signals only + +The `detect` command SHALL emit only deterministic signals derived from files on +disk: configured linters, detected languages, the styles of the repo's own +existing rules, the CI systems configured for the repository, and the tools that +run commands at commit time. It SHALL NOT perform any LLM inference and SHALL NOT match the +request against any catalog of known packaged linter rules. + +Detection follows a languages → linters flow: languages are inferred first, and +a linter's dependency evidence is then read from the manifest of that linter's +own language (a node dependency from `package.json`, a Python dependency from +`pyproject.toml`/`requirements.txt`) rather than conflating ecosystems. A +recognized linter config file on disk is honored regardless of the languages +inferred. + +CI systems and commit-hook tools are matched at the scan root only, never in a +sub-package. A CI system reads its configuration from the repository root and git +runs one set of hooks per repository, so a match further down the tree is a +fixture or a vendored project rather than this repository's configuration. Each +is reported as a `name` with the `evidence` that matched, in the same shape as a +linter. + +#### Scenario: Linter configs are detected from disk + +- **WHEN** the working directory contains a recognized linter config (for + example `.eslintrc*`, `eslint.config.js`, `ruff.toml`, a `[tool.ruff]` block in + `pyproject.toml`, `.rubocop.yml`, `biome.json`, `.oxlintrc.json`, or + `stylelint` config) +- **THEN** `detect --json` SHALL report each configured linter it found + +#### Scenario: Languages are reported + +- **WHEN** `detect --json` runs in a repository +- **THEN** the output SHALL include the languages inferred from manifest and + marker files present on disk and from the linters detected + +#### Scenario: A linter dependency is sourced from its own language's manifest + +- **WHEN** a dependency-evidenced linter (for example `ruff`) is named only in a + manifest belonging to a different language (for example `package.json`) +- **THEN** `detect --json` SHALL NOT report that linter from the mismatched + manifest + +#### Scenario: oxlint is detected from its config or its dependency + +- **WHEN** the working directory contains one of the config files oxlint + discovers on its own (`.oxlintrc.json`, `.oxlintrc.jsonc`, `oxlint.config.ts`, + `oxlint.config.mts`), or names `oxlint` as a dependency in `package.json` +- **THEN** `detect --json` SHALL report `oxlint` as a JavaScript/TypeScript linter +- **AND** a repository configured only for eslint SHALL NOT report `oxlint` + +#### Scenario: Configs in monorepo sub-packages are detected + +- **WHEN** a linter config or language manifest lives in a sub-package rather + than the repository root (for example `packages/api/.eslintrc.json`) +- **THEN** `detect --json` SHALL detect it and SHALL carry the path it was found + at in the linter's evidence +- **AND** the scan SHALL prune a curated set of ignored directories (for example + `node_modules`, `.git`, build output) and SHALL bound traversal depth + +#### Scenario: The repo's own rule styles are surfaced + +- **WHEN** the working directory contains existing rule definitions (for example + custom linter rules or `.taskless/rules/`) +- **THEN** `detect --json` SHALL surface a description of those existing rule + styles for downstream authoring + +#### Scenario: No packaged-rule catalog matching + +- **WHEN** `detect --json` runs +- **THEN** the output SHALL NOT claim a request maps to a specific named packaged + rule (such matching is left to the authoring recipe, not the command) + +#### Scenario: CI systems are detected from their root config + +- **WHEN** the scan root contains a recognized CI configuration (for example + `.github/workflows/*.yml`, `.gitlab-ci.yml`, `.circleci/config.yml`, + `Jenkinsfile`, `azure-pipelines.yml`, `bitbucket-pipelines.yml`, + `.buildkite/`, `.drone.yml`, or `.travis.yml`) +- **THEN** `detect --json` SHALL report each such CI system under `ci`, with the + matching paths as its evidence + +#### Scenario: A CI config below the root is not this repository's CI + +- **WHEN** a CI configuration file exists only in a sub-directory (for example + `packages/api/.gitlab-ci.yml`) +- **THEN** `detect --json` SHALL NOT report that CI system + +#### Scenario: Commit-hook tools are detected from config or root dependency + +- **WHEN** the scan root contains a recognized hook tool's configuration (for + example a `.husky/` directory, `lefthook.yml`, `.pre-commit-config.yaml`, a + `simple-git-hooks` or `lint-staged` config file, or a `simple-git-hooks` or + `lint-staged` key in the root `package.json`), or the root `package.json` + names the tool as a dependency +- **THEN** `detect --json` SHALL report the tool under `hooks`, with what matched + as its evidence + +#### Scenario: No CI and no hooks are reported as empty lists + +- **WHEN** the scan root carries no recognized CI configuration and no + recognized hook tool +- **THEN** `detect --json` SHALL report `ci` and `hooks` as empty arrays rather + than omitting them + +### Requirement: Detect emits a stable JSON shape + +When `--json` is set, `detect` SHALL emit a single structured JSON object whose +shape is validated internally against a stable Zod output schema before being +printed, consistent with how other `--json` commands in the CLI (e.g. `info`, +`check`) validate their output. The schema is an internal contract, not a +published artifact, and `detect` does not expose a `--schema` mode. + +#### Scenario: JSON output validates against the internal schema + +- **WHEN** `detect --json` succeeds +- **THEN** stdout SHALL be a single JSON object that the command has validated + against its internal output schema (linters, languages, existing rule styles, + CI systems, commit-hook tools) diff --git a/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/specs/cli-onboard/spec.md b/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/specs/cli-onboard/spec.md new file mode 100644 index 00000000..eed76cda --- /dev/null +++ b/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/specs/cli-onboard/spec.md @@ -0,0 +1,114 @@ +## MODIFIED Requirements + +### Requirement: Onboard recipe follows the canonical recipe template and is conversational + +The `onboard.md` file SHALL follow the canonical recipe template defined in the `cli-agent` capability (header with CLI version + topic version, `## Goal`, `## Preconditions`, `## Steps`, `## Errors`, `## See Also`). The `## Steps` section SHALL describe a conversational discovery flow rather than a fixed sequence. Specifically, the recipe SHALL instruct the agent to: + +1. Read `.taskless/taskless.json` and respect the `install.onboarded` field. +2. Establish the routing surface before proposing any candidate, by fetching the `route` topic for the destination criterion and running `taskless detect --json` for the repository's linters, languages, and rule styles. +3. Open the conversation with a short menu of known sources for rule candidates: codebase TODOs/FIXMEs (via ripgrep or built-in search), agent-memory files (CLAUDE.md, AGENTS.md, .cursorrules, etc.), recent PR review comments, and bug-tracker tickets. +4. Encourage the user to suggest additional sources the agent may not know about. +5. Report the command-line tools the CLI already found, rather than instruct the agent to probe for them. The recipe SHALL NOT instruct the agent to run `command -v` or any equivalent probe for a tool the CLI reports on. +6. For each chosen source, scan and filter for high-signal candidates: repeated patterns across multiple PRs/files/comments, comments that cite a doc or style guide, and merge-blocking review feedback. Filter out one-off nits and pure formatting feedback. +7. Synthesize a single bullet list where each bullet is a hypothetical rule expressed as ` []: `. +8. For each bullet, offer to materialize it by following the `route` topic, with the accepted bullet as the rule description input. +9. Once the user has materialized what they want, decide with the user when the rules run. The recipe SHALL instruct the agent to report the CI systems and commit-hook tools `taskless detect --json` found, to offer wiring `check` into CI by following the `ci` topic and into a pre-commit hook by following the `hooks` topic, and to carry out whichever the user picks. Declining both SHALL be an accepted answer, and the agent SHALL say in one line that `check` then runs only when someone invokes it. +10. Only after that, ask the user whether they consider onboarding complete; on explicit yes, run `taskless onboard --mark-complete`. + +The recipe SHALL describe a detected tool as present, never as verified. The CLI establishes presence by looking for a file on `PATH` and executes nothing, so the recipe SHALL say so — "`gh` is on your PATH; Taskless did not run it" — and SHALL NOT assert that a tool works, is a particular version, or is genuine. + +The recipe SHALL NOT name one bug tracker as the expected one. It MAY name trackers such as Jira and Linear as examples of the class. Whether a tracker is reachable depends on the agent's MCP roster, which the CLI cannot see, so the recipe SHALL leave that judgement to the agent at runtime and SHALL condition the source on a relevant MCP being available rather than on any named vendor. + +When a source is not offered, the recipe SHALL say in one line why it is not offered. A reader who is not told reads the omission as an oversight and asks for it, which costs a turn and ends where the recipe already is. Where a source is unavailable for more than one reason, the reason that cannot be remedied SHALL be the one stated: a repository with no GitHub `origin` SHALL be told there are no pull requests to mine, not that `gh` is missing, and SHALL NOT be offered PR-comment mining even when `gh` is present. + +The recipe SHALL NOT restate the destination criterion itself. That comparison is defined once, in the `route` topic, and the recipe SHALL reference it rather than duplicate it. + +The recipe SHALL warn the agent against marking onboarding complete without explicit user confirmation. + +The recipe SHALL require the mark-complete question to be asked in a message that asks nothing else. A reply to a message carrying two questions cannot be read as explicit consent to either, and `--mark-complete` is consent-gated, so the recipe SHALL NOT let the question share a message with the step that decides when the rules run. + +#### Scenario: Recipe header includes CLI and topic version + +- **WHEN** `onboard.md` is read +- **THEN** the first line SHALL match the canonical header format `# Topic: onboard (CLI v / topic v)` + +#### Scenario: Recipe establishes the routing surface before proposing candidates + +- **WHEN** the recipe `## Steps` section is read +- **THEN** a step instructing the agent to fetch the `route` topic and to run `detect --json` SHALL appear before the step that synthesizes the bullet list + +#### Scenario: Recipe does not duplicate the destination criterion + +- **WHEN** `onboard.md` is read +- **THEN** it SHALL NOT contain a table or enumeration comparing the rule destinations against one another +- **AND** it SHALL direct the agent to the `route` topic for that comparison + +#### Scenario: Recipe enumerates the known source menu + +- **WHEN** the recipe `## Steps` section is read with no host-tool state supplied +- **THEN** it SHALL list at least: codebase TODOs/FIXMEs, agent-memory files, PR review comments, and bug-tracker tickets +- **AND** it SHALL NOT instruct the agent to probe for `gh` + +#### Scenario: A present and applicable tool is offered as present + +- **WHEN** the recipe is rendered for a GitHub repository on a host where `gh` is on `PATH` +- **THEN** the PR-review source SHALL be offered +- **AND** the text SHALL state that `gh` is on the PATH and that Taskless did not run it + +#### Scenario: An absent tool is omitted with its reason + +- **WHEN** the recipe is rendered for a GitHub repository on a host where `gh` is not on `PATH` +- **THEN** the PR-review source SHALL NOT be offered +- **AND** the recipe SHALL state in one line that `gh` was not found on the PATH + +#### Scenario: A repository with no GitHub origin is told the source does not apply + +- **WHEN** the recipe is rendered for a repository whose `ghOwner` resolves to `[unknown]`, whether or not `gh` is on `PATH` +- **THEN** the PR-review source SHALL NOT be offered +- **AND** the stated reason SHALL be that the repository has no GitHub origin, not that `gh` is missing + +#### Scenario: Recipe encourages user-suggested sources + +- **WHEN** the recipe `## Steps` section is read +- **THEN** it SHALL explicitly instruct the agent to ask the user whether other sources should be scanned + +#### Scenario: Recipe specifies the annotated bullet output shape + +- **WHEN** the recipe `## Steps` section is read +- **THEN** it SHALL describe the rule-candidate output as a bullet list with ` []: ` per item +- **AND** SHALL state that the annotation is provisional and that the `route` topic decides the destination at materialization time + +#### Scenario: Recipe gates --mark-complete on user confirmation + +- **WHEN** the recipe `## Steps` section is read +- **THEN** it SHALL instruct the agent to ask for explicit user confirmation before invoking `taskless onboard --mark-complete` +- **AND** SHALL warn that the agent must NOT mark onboarding complete without that confirmation + +#### Scenario: Recipe references the rule-authoring route topic in See Also + +- **WHEN** the `## See Also` section is read +- **THEN** it SHALL include a reference to `taskless agent route` + +#### Scenario: Recipe decides when the rules run before asking to mark complete + +- **WHEN** the recipe `## Steps` section is read +- **THEN** a step that offers wiring `check` into CI via the `ci` topic and into a pre-commit hook via the `hooks` topic SHALL appear after the materialization step +- **AND** it SHALL appear before the step that asks about `--mark-complete` +- **AND** it SHALL direct the agent to the `ci` and `hooks` fields of `detect --json` for what the repository already has + +#### Scenario: Declining both is an accepted answer + +- **WHEN** the recipe's step that decides when the rules run is read +- **THEN** it SHALL accept a user who wants neither CI nor a hook +- **AND** SHALL instruct the agent to state that `check` then runs only when someone invokes it + +#### Scenario: The mark-complete question is asked alone + +- **WHEN** the recipe step that asks about `--mark-complete` is read +- **THEN** it SHALL instruct the agent to ask that question in a message containing no other question + +#### Scenario: Recipe references the ci and hooks topics in See Also + +- **WHEN** the `## See Also` section is read +- **THEN** it SHALL include a reference to `taskless agent ci` +- **AND** it SHALL include a reference to `taskless agent hooks` diff --git a/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/tasks.md b/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/tasks.md new file mode 100644 index 00000000..4f52cf04 --- /dev/null +++ b/openspec/changes/archive/2026-10-05-onboard-decide-when-rules-run/tasks.md @@ -0,0 +1,27 @@ +## 1. detect reports CI systems and commit-hook tools + +- [x] 1.1 Add `ci` and `hooks` (`{ name, evidence }[]`) to `DetectResult` in `packages/cli/src/detect/scan.ts`, matched at the scan root only +- [x] 1.2 Add both fields to the `detect` output schema +- [x] 1.3 Tests in `detect.test.ts`: each CI system and hook tool is reported with evidence, a sub-directory CI config is not, and both are empty arrays on a bare repository +- [x] 1.4 Update `detect.md` (topic v1 → v2) with the new fields + +## 2. The hooks topic + +- [x] 2.1 Write `packages/cli/src/agent/hooks.md` (topic v1) +- [x] 2.2 Add `hooks` to `INTERNAL_TOPICS` in `packages/cli/src/prompts/index.ts` +- [x] 2.3 Keep `hooks.md` clear of the hand-written-invocation guard in `recipe-cross-references.test.ts` without an allowlist entry +- [x] 2.4 Add a `hooks` row to the topic table in `skills/taskless/SKILL.md` +- [x] 2.5 Point the `ci` recipe at `detect --json`'s `ci` field and at `agent hooks` in See Also (topic v3 → v4) +- [x] 2.6 Name `agent hooks` in the `check` recipe's See Also (topic v6 → v7) + +## 3. The onboard step + +- [x] 3.1 Add the "decide when the rules run" step to `onboard.md` between materializing and marking complete, and require the mark-complete question to be asked alone (topic v4 → v5) +- [x] 3.2 Add `agent ci` and `agent hooks` to onboard's See Also +- [x] 3.3 Tests in `onboard.test.ts` for the step order, the decline path, the lone question, and See Also + +## 4. Ship + +- [x] 4.1 Add a patch changeset +- [x] 4.2 Run `pnpm typecheck`, `pnpm lint`, and the CLI test suite +- [x] 4.3 Run the pre-archive scenario check, then archive the change diff --git a/openspec/specs/cli-agent/spec.md b/openspec/specs/cli-agent/spec.md index 776ff966..ba8638b6 100644 --- a/openspec/specs/cli-agent/spec.md +++ b/openspec/specs/cli-agent/spec.md @@ -339,3 +339,39 @@ The directive is added by the renderer on the `agent` command's request, not wri - **WHEN** a recipe is rendered with the directive and `header: false` - **THEN** the result SHALL contain neither the `# Topic:` line, the CLI version, nor the directive - **AND** its body SHALL be byte-identical to the body of the default rendering + +### Requirement: hooks topic is registered + +An agent topic `hooks` SHALL be registered. The CLI SHALL embed `packages/cli/src/agent/hooks.md` at build time via the existing `import.meta.glob` mechanism, and `taskless agent hooks` SHALL print it. The topic SHALL be classified as internal to the CLI rather than exported from `@taskless/cli/prompts`, since it walks an agent through editing a developer's repository. + +The recipe SHALL describe running `check` on the staged files before a commit, in the hook tool the repository already uses, and SHALL NOT install a hook manager without asking the user first. It SHALL direct the agent to the `hooks` field of `taskless detect --json` for which tools the repository has. + +The recipe SHALL be accurate about `check`'s behavior where a hook depends on it: + +- `check` accepts file paths and skips paths that do not exist, so a staged-file list that includes deleted files can be passed directly, and a hook SHALL exit cleanly rather than run a full scan when the staged list is empty. +- `check` edits no tracked file. Where the recipe offers lint-staged's `--no-stash` on that basis, it SHALL also state that the flag makes lint-staged check a partially staged file as it is in the working tree rather than as it is staged. +- A staged change under `.taskless/rules/` SHALL be followed by `taskless test` and a full `check`, since a rule change can affect files that were not staged. + +The recipe SHALL have the hook run the repository's own pinned `@taskless/cli` dev dependency rather than a download-and-run launcher, so the hook, CI and every developer run one version. + +#### Scenario: The hooks topic returns the recipe + +- **WHEN** a user runs `taskless agent hooks` +- **THEN** the CLI SHALL print the contents of `hooks.md` to stdout +- **AND** SHALL exit with code 0 + +#### Scenario: The hooks topic is internal + +- **WHEN** the topic classification in `@taskless/cli/prompts` is read +- **THEN** `hooks` SHALL be listed among the internal topics +- **AND** SHALL NOT be an exported prompt topic + +#### Scenario: The recipe states the cost of --no-stash + +- **WHEN** `hooks.md` mentions lint-staged's `--no-stash` +- **THEN** it SHALL state that a partially staged file is then checked as it is in the working tree + +#### Scenario: A rule change triggers test and a full check + +- **WHEN** `hooks.md` is read +- **THEN** it SHALL instruct the agent to run `taskless test` and an unscoped `check` when a staged path is under `.taskless/rules/` diff --git a/openspec/specs/cli-detect/spec.md b/openspec/specs/cli-detect/spec.md index 48a07c44..01ba9131 100644 --- a/openspec/specs/cli-detect/spec.md +++ b/openspec/specs/cli-detect/spec.md @@ -21,8 +21,9 @@ flag. ### Requirement: Detect scans deterministic repo signals only The `detect` command SHALL emit only deterministic signals derived from files on -disk: configured linters, detected languages, and the styles of the repo's own -existing rules. It SHALL NOT perform any LLM inference and SHALL NOT match the +disk: configured linters, detected languages, the styles of the repo's own +existing rules, the CI systems configured for the repository, and the tools that +run commands at commit time. It SHALL NOT perform any LLM inference and SHALL NOT match the request against any catalog of known packaged linter rules. Detection follows a languages → linters flow: languages are inferred first, and @@ -32,6 +33,13 @@ own language (a node dependency from `package.json`, a Python dependency from recognized linter config file on disk is honored regardless of the languages inferred. +CI systems and commit-hook tools are matched at the scan root only, never in a +sub-package. A CI system reads its configuration from the repository root and git +runs one set of hooks per repository, so a match further down the tree is a +fixture or a vendored project rather than this repository's configuration. Each +is reported as a `name` with the `evidence` that matched, in the same shape as a +linter. + #### Scenario: Linter configs are detected from disk - **WHEN** the working directory contains a recognized linter config (for @@ -83,6 +91,38 @@ inferred. - **THEN** the output SHALL NOT claim a request maps to a specific named packaged rule (such matching is left to the authoring recipe, not the command) +#### Scenario: CI systems are detected from their root config + +- **WHEN** the scan root contains a recognized CI configuration (for example + `.github/workflows/*.yml`, `.gitlab-ci.yml`, `.circleci/config.yml`, + `Jenkinsfile`, `azure-pipelines.yml`, `bitbucket-pipelines.yml`, + `.buildkite/`, `.drone.yml`, or `.travis.yml`) +- **THEN** `detect --json` SHALL report each such CI system under `ci`, with the + matching paths as its evidence + +#### Scenario: A CI config below the root is not this repository's CI + +- **WHEN** a CI configuration file exists only in a sub-directory (for example + `packages/api/.gitlab-ci.yml`) +- **THEN** `detect --json` SHALL NOT report that CI system + +#### Scenario: Commit-hook tools are detected from config or root dependency + +- **WHEN** the scan root contains a recognized hook tool's configuration (for + example a `.husky/` directory, `lefthook.yml`, `.pre-commit-config.yaml`, a + `simple-git-hooks` or `lint-staged` config file, or a `simple-git-hooks` or + `lint-staged` key in the root `package.json`), or the root `package.json` + names the tool as a dependency +- **THEN** `detect --json` SHALL report the tool under `hooks`, with what matched + as its evidence + +#### Scenario: No CI and no hooks are reported as empty lists + +- **WHEN** the scan root carries no recognized CI configuration and no + recognized hook tool +- **THEN** `detect --json` SHALL report `ci` and `hooks` as empty arrays rather + than omitting them + ### Requirement: Detect runs offline with no network or auth The `detect` command SHALL complete without network access and without @@ -106,4 +146,5 @@ published artifact, and `detect` does not expose a `--schema` mode. - **WHEN** `detect --json` succeeds - **THEN** stdout SHALL be a single JSON object that the command has validated - against its internal output schema (linters, languages, existing rule styles) + against its internal output schema (linters, languages, existing rule styles, + CI systems, commit-hook tools) diff --git a/openspec/specs/cli-onboard/spec.md b/openspec/specs/cli-onboard/spec.md index 37e9b285..d2fc9083 100644 --- a/openspec/specs/cli-onboard/spec.md +++ b/openspec/specs/cli-onboard/spec.md @@ -123,7 +123,8 @@ The `onboard.md` file SHALL follow the canonical recipe template defined in the 6. For each chosen source, scan and filter for high-signal candidates: repeated patterns across multiple PRs/files/comments, comments that cite a doc or style guide, and merge-blocking review feedback. Filter out one-off nits and pure formatting feedback. 7. Synthesize a single bullet list where each bullet is a hypothetical rule expressed as ` []: `. 8. For each bullet, offer to materialize it by following the `route` topic, with the accepted bullet as the rule description input. -9. At the end, ask the user whether they consider onboarding complete; on explicit yes, run `taskless onboard --mark-complete`. +9. Once the user has materialized what they want, decide with the user when the rules run. The recipe SHALL instruct the agent to report the CI systems and commit-hook tools `taskless detect --json` found, to offer wiring `check` into CI by following the `ci` topic and into a pre-commit hook by following the `hooks` topic, and to carry out whichever the user picks. Declining both SHALL be an accepted answer, and the agent SHALL say in one line that `check` then runs only when someone invokes it. +10. Only after that, ask the user whether they consider onboarding complete; on explicit yes, run `taskless onboard --mark-complete`. The recipe SHALL describe a detected tool as present, never as verified. The CLI establishes presence by looking for a file on `PATH` and executes nothing, so the recipe SHALL say so — "`gh` is on your PATH; Taskless did not run it" — and SHALL NOT assert that a tool works, is a particular version, or is genuine. @@ -135,6 +136,8 @@ The recipe SHALL NOT restate the destination criterion itself. That comparison i The recipe SHALL warn the agent against marking onboarding complete without explicit user confirmation. +The recipe SHALL require the mark-complete question to be asked in a message that asks nothing else. A reply to a message carrying two questions cannot be read as explicit consent to either, and `--mark-complete` is consent-gated, so the recipe SHALL NOT let the question share a message with the step that decides when the rules run. + #### Scenario: Recipe header includes CLI and topic version - **WHEN** `onboard.md` is read @@ -197,6 +200,30 @@ The recipe SHALL warn the agent against marking onboarding complete without expl - **WHEN** the `## See Also` section is read - **THEN** it SHALL include a reference to `taskless agent route` +#### Scenario: Recipe decides when the rules run before asking to mark complete + +- **WHEN** the recipe `## Steps` section is read +- **THEN** a step that offers wiring `check` into CI via the `ci` topic and into a pre-commit hook via the `hooks` topic SHALL appear after the materialization step +- **AND** it SHALL appear before the step that asks about `--mark-complete` +- **AND** it SHALL direct the agent to the `ci` and `hooks` fields of `detect --json` for what the repository already has + +#### Scenario: Declining both is an accepted answer + +- **WHEN** the recipe's step that decides when the rules run is read +- **THEN** it SHALL accept a user who wants neither CI nor a hook +- **AND** SHALL instruct the agent to state that `check` then runs only when someone invokes it + +#### Scenario: The mark-complete question is asked alone + +- **WHEN** the recipe step that asks about `--mark-complete` is read +- **THEN** it SHALL instruct the agent to ask that question in a message containing no other question + +#### Scenario: Recipe references the ci and hooks topics in See Also + +- **WHEN** the `## See Also` section is read +- **THEN** it SHALL include a reference to `taskless agent ci` +- **AND** it SHALL include a reference to `taskless agent hooks` + ### Requirement: Onboard emits intent telemetry The `taskless onboard` subcommand SHALL emit PostHog events on every invocation: diff --git a/packages/cli/src/agent/check.md b/packages/cli/src/agent/check.md index ef5f7246..205bd0f4 100644 --- a/packages/cli/src/agent/check.md +++ b/packages/cli/src/agent/check.md @@ -1,4 +1,4 @@ -# Topic: check (CLI v%(CLI_VERSION)s / topic v6) +# Topic: check (CLI v%(CLI_VERSION)s / topic v7) ## Goal Run the applicable rules against the codebase and report matches. Two @@ -277,3 +277,4 @@ When `--json` is set, failures emit `{ ok: false, code, message }`: - `%(TASKLESS_CLI)s agent route`: add a rule if none exist - `%(TASKLESS_CLI)s agent ci`: wire `check` into a CI pipeline +- `%(TASKLESS_CLI)s agent hooks`: run `check` on staged files before a commit diff --git a/packages/cli/src/agent/ci.md b/packages/cli/src/agent/ci.md index 147f3f12..42580e4d 100644 --- a/packages/cli/src/agent/ci.md +++ b/packages/cli/src/agent/ci.md @@ -1,4 +1,4 @@ -# Topic: ci (CLI v%(CLI_VERSION)s / topic v3) +# Topic: ci (CLI v%(CLI_VERSION)s / topic v4) ## Goal Wire `%(TASKLESS_CLI)s check` into the user's existing CI so rules run @@ -23,7 +23,10 @@ you recognize one not on the list, apply the same patterns. ### 1. Discover the user's CI system -Scan the repo root for CI config files. Hints (not exhaustive): +Start from `%(TASKLESS_CLI)s detect --json`: its `ci` field names the CI +systems configured at the repository root, with the files that matched. +It recognizes the systems below. If it reports none, or you recognize a +system it does not, look at the repo root yourself: | File / directory | CI system | |---------------------------|---------------------| @@ -238,3 +241,4 @@ Show: - `%(TASKLESS_CLI)s agent check`: the command being wired into CI - `%(TASKLESS_CLI)s agent route`: required if no rules exist yet +- `%(TASKLESS_CLI)s agent hooks`: run `check` on staged files before a commit, too diff --git a/packages/cli/src/agent/detect.md b/packages/cli/src/agent/detect.md index faf3e7f8..62ffaee5 100644 --- a/packages/cli/src/agent/detect.md +++ b/packages/cli/src/agent/detect.md @@ -1,9 +1,10 @@ -# Topic: detect (CLI v%(CLI_VERSION)s / topic v1) +# Topic: detect (CLI v%(CLI_VERSION)s / topic v2) ## Goal Scan the working directory for the linters it configures, the -languages it uses, and the styles of any rules the repo already -authors. Offline and deterministic, no network, no auth, no state +languages it uses, the styles of any rules the repo already +authors, and what already runs commands for it: CI systems and +commit-hook tools. Offline and deterministic, no network, no auth, no state change. This is the discovery step that feeds rule-authoring: the routing flow reads `detect` to decide where a new rule should live. @@ -33,6 +34,18 @@ routing flow reads `detect` to decide where a new rule should live. "source": ".taskless/rules/sg", "description": "ast-grep rules with YAML metadata sidecars" } + ], + "ci": [ + { + "name": "github-actions", + "evidence": [".github/workflows/test.yml"] + } + ], + "hooks": [ + { + "name": "husky", + "evidence": [".husky/", "dependency husky (package.json)"] + } ] } ``` @@ -42,6 +55,19 @@ routing flow reads `detect` to decide where a new rule should live. - `languages`: inferred from manifests and the detected linters. - `ruleStyles`: how the repo authors its own rules, surfaced for downstream reuse. + - `ci`: CI systems configured at the scan root (`github-actions`, + `gitlab-ci`, `circleci`, `jenkins`, `azure-pipelines`, + `bitbucket-pipelines`, `buildkite`, `drone`, `travis-ci`). + - `hooks`: tools that run commands at commit time, configured at + the scan root (`husky`, `lefthook`, `pre-commit`, + `simple-git-hooks`, `lint-staged`). lint-staged is listed even + though a hook manager has to call it, because it is what hands + the staged files to a command. + + `ci` and `hooks` are read at the scan root only. A CI config or + hook setup inside a sub-package is not this repository's, so it is + not reported. Both say what is configured, not whether it already + runs Taskless: read the files for that. 3. **Use the signals to route.** Feed the output into rule authoring: - A detected linter the repo already uses → author the rule there @@ -61,3 +87,5 @@ When `--json` is set, failures emit `{ ok: false, code, message }`: - `%(TASKLESS_CLI)s agent route`: decide where to author a rule from these signals - `%(TASKLESS_CLI)s agent check`: run rules against the codebase +- `%(TASKLESS_CLI)s agent ci`: wire `check` into the CI systems `ci` reports +- `%(TASKLESS_CLI)s agent hooks`: run `check` before a commit, in the tool `hooks` reports diff --git a/packages/cli/src/agent/hooks.md b/packages/cli/src/agent/hooks.md new file mode 100644 index 00000000..fae744f0 --- /dev/null +++ b/packages/cli/src/agent/hooks.md @@ -0,0 +1,220 @@ +# Topic: hooks (CLI v%(CLI_VERSION)s / topic v1) + +## Goal +Run `check` on the staged files before every commit, in the hook tool +the repository already uses, so a rule violation is caught before it +reaches a branch rather than after CI runs. This is the local half of +deciding when rules run. `%(TASKLESS_CLI)s agent ci` is the other half. + +## Preconditions +- `.taskless/` exists and holds at least one rule. A hook with no + rules passes every commit and says nothing, which reads as "checked + and clean". If there are none, fetch `%(TASKLESS_CLI)s agent route` + first. +- `%(TASKLESS_CLI)s check` succeeds locally, or fails only with + findings the user has agreed to fix. Otherwise the hook blocks the + very next commit. +- The user has agreed to a hook. A hook runs on every developer's + commits, so it is a team decision, not a setup detail. + +## Steps + +### 1. Find the hook tool the repository already uses + +``` +%(TASKLESS_CLI)s detect --json +``` + +Read the `hooks` field. Each entry names a tool and the evidence that +matched: + +| `hooks` entry | Where the pre-commit command goes | +|--------------------|-----------------------------------------------------------| +| `lint-staged` | its config: `.lintstagedrc*`, `lint-staged.config.*`, or the `lint-staged` key in `package.json` | +| `husky` | `.husky/pre-commit` | +| `lefthook` | `lefthook.yml` (or its `.json`/`.toml` sibling), under `pre-commit` | +| `pre-commit` | `.pre-commit-config.yaml`, as a `repo: local` hook | +| `simple-git-hooks` | its config file or the `simple-git-hooks` key in `package.json` | + +When `lint-staged` is present beside a hook manager, add the command +to lint-staged and leave the hook manager calling it as it already +does. When `hooks` is empty, ask the user which tool they want and do +not install one on your own initiative. A script placed directly in +`.git/hooks/` is not committed, so it runs only on the machine that +wrote it. Say so if the user asks for one. + +### 2. Pin the CLI as a dev dependency + +The hook runs the version the repository pins, so the hook, CI and +every developer agree on what `check` means. A download-and-run +launcher fetches whatever is newest at commit time instead. + +If `@taskless/cli` is not already in `devDependencies`, offer to add +it with the repository's package manager (read the lockfile: +`pnpm-lock.yaml`, `yarn.lock`, `bun.lockb`, or `package-lock.json`). + +The examples below write `` for that pinned binary. +Substitute the form that resolves it from the repository's own +`node_modules/.bin`: + +- pnpm: `pnpm exec taskless` +- npm: `npx --no taskless` +- yarn: `yarn taskless` +- bun: `bun run taskless` + +The npm form keeps `--no` on purpose: without it, npx downloads an +unrelated package named `taskless` when the dev dependency is missing, +rather than failing. + +Inside a lint-staged task a bare `taskless` also resolves, because +lint-staged puts `node_modules/.bin` on the `PATH` for its tasks. + +### 3. Write the pre-commit command + +`check` accepts file paths and skips any that do not exist, so a staged +list that includes deleted files can go straight in. Called with no +paths at all it scans the whole project, so a hook that builds the list +itself has to exit early when the list is empty. + +**lint-staged.** lint-staged appends the staged files that match the +glob to the command: + +```json +{ + "*": " check" +} +``` + +**lefthook.** `{staged_files}` expands to the staged list, and +lefthook skips the command when it is empty: + +```yaml +pre-commit: + commands: + taskless: + run: check {staged_files} +``` + +**pre-commit.** pre-commit passes the staged filenames to `entry`: + +```yaml +repos: + - repo: local + hooks: + - id: taskless + name: Taskless + entry: check + language: system + pass_filenames: true +``` + +**husky or simple-git-hooks without lint-staged.** Build the list +with git: + +```sh +FILES=$(git diff --cached --name-only --diff-filter=ACMR) +[ -z "$FILES" ] && exit 0 + check $FILES +``` + +`--diff-filter=ACMR` drops deleted files up front. `check` would skip +them anyway; the filter keeps a commit that only deletes files from +reaching `check` at all. + +`$FILES` splits on whitespace, so a staged path containing a space +reaches `check` as two paths that do not exist, and is skipped. The +`ci` recipe has the same limitation. If the repository has paths +like that, prefer lint-staged. + +For simple-git-hooks, rerun its installer after editing the config +(` simple-git-hooks`), or the change does not reach +`.git/hooks/`. + +What the hook runs depends on the developer's login, as for any +`check` (see "What runs" in `%(TASKLESS_CLI)s agent check`). Logged +in, `check` verifies the rules with the Taskless service, a network +call on every commit, and runs the runtime rules that pass. Logged +out, it runs ast-grep and Vale rules and skips runtime ones. If that +network call makes commits too slow, `--anonymous` skips it, and with +it the runtime rules and the edited-rule check; that is the user's +call, and CI with a token stays the backstop. Never add +`--dangerously-run-scripts`: it executes rule code nobody verified, +on every commit. + +### 4. Rerun everything when a rule changes + +A staged change under `.taskless/rules/` changes what `check` means, +and it can affect files that were not staged. In that case run the +rule tests and a full, unscoped `check`: + +``` + test + check +``` + +lint-staged can express that in a JavaScript config, where a function +task receives the matched files and its returned commands are run as +written, with no files appended: + +```js +export default { + "*": " check", + ".taskless/rules/**": () => [ + " test", + " check", + ], +}; +``` + +For the other tools, add a second command scoped to +`.taskless/rules/`, using the tool's own glob or file filter. Check +the tool's documentation for the exact key. + +### 5. lint-staged and `--no-stash` + +`check` edits no tracked file: its working files go under +`.taskless/.run/` and are removed when the run ends. So lint-staged's +backup stash protects nothing for a `check` task, and it goes on the +stash stack every git worktree of the repository shares, which +matters where several agents work in worktrees at once. + +`--no-stash` skips that stash, but lint-staged documents that it also +implies `--no-hide-partially-staged`. A file with both staged and +unstaged edits is then checked as it is in the working tree, not as +it is staged, so a commit can pass on an edit it does not contain. +Present both sides and let the user choose. Do not add the flag +silently. If lint-staged also runs a formatter that rewrites files, +keep the stash: `--no-stash` would leave that formatter's changes +unprotected when the commit is aborted. + +### 6. Verify + +Stage a change and run the hook directly rather than committing: + +- lint-staged: ` lint-staged` +- lefthook: ` lefthook run pre-commit` +- pre-commit: `pre-commit run` +- husky or simple-git-hooks: run the hook script, `sh .husky/pre-commit` + +Confirm it scans the staged files, and that it exits 0 when nothing +is staged. + +### 7. Report back + +Show the file you changed and the lines you added, any dev dependency +you added, and `git status`. Do not commit on the user's behalf. + +## Errors + +- **No rules**: fetch `%(TASKLESS_CLI)s agent route`. Do not write a hook. +- **Local `check` fails**: fix, suppress, or agree with the user to + leave it, before the hook goes in. A hook that fails on code nobody + staged blocks every commit. +- **The user declines a hook**: stop. `check` then runs only in CI, if + wired, or when someone runs it. + +## See Also + +- `%(TASKLESS_CLI)s agent check`: what `check` runs and what its exit codes mean +- `%(TASKLESS_CLI)s agent ci`: run `check` in CI as well +- `%(TASKLESS_CLI)s agent detect`: the `hooks` field this recipe starts from diff --git a/packages/cli/src/agent/onboard.md b/packages/cli/src/agent/onboard.md index 88244975..a134be5c 100644 --- a/packages/cli/src/agent/onboard.md +++ b/packages/cli/src/agent/onboard.md @@ -1,4 +1,4 @@ -# Topic: onboard (CLI v%(CLI_VERSION)s / topic v4) +# Topic: onboard (CLI v%(CLI_VERSION)s / topic v5) ## Goal Help a user who has just installed Taskless go from zero rules to a @@ -126,12 +126,34 @@ rules as a bullet list the user can choose to materialize via the bullet becomes the rule description input. Re-fetch it only if it has fallen out of context. -8. **Ask before marking onboarding complete.** When the user signals - they're done (they've materialized everything they want, or - they've said "that's enough for now"), explicitly ask: "Do you - want me to mark Taskless as onboarded? You won't be re-asked to - onboard until you pass `--force`." On explicit yes, and only - on explicit yes, run: +8. **Decide when the rules run.** Once the user has materialized + what they want, rules exist that nothing runs yet. Settle that + before anything else, as part of onboarding rather than after it. + + Read the `ci` and `hooks` fields of the `detect --json` output you + already have, and tell the user what the repository has: which CI + systems are configured and which commit-hook tools, or that there + are none. Then offer the two ways the rules can run on their own: + + - **In CI**, on pushes and pull requests: follow + `%(TASKLESS_CLI)s agent ci`. + - **Before each commit**, on the staged files: follow + `%(TASKLESS_CLI)s agent hooks`. + + The user can pick either, both, or neither. Carry out what they + pick before moving on. If they pick neither, accept it and say in + one line that `%(TASKLESS_CLI)s check` then runs only when someone + invokes it. + +9. **Ask before marking onboarding complete.** When the user signals + they're done (they've materialized everything they want, decided + when the rules run, or said "that's enough for now"), explicitly + ask: "Do you want me to mark Taskless as onboarded? You won't be + re-asked to onboard until you pass `--force`." Ask it in a message + with no other question in it. A "yes" to a message that asks two + things is not consent to either, so never pair this question with + the previous step's offer. On explicit yes, and only on explicit + yes, run: ``` %(TASKLESS_CLI)s onboard --mark-complete @@ -154,4 +176,6 @@ rules as a bullet list the user can choose to materialize via the - `%(TASKLESS_CLI)s agent detect`: what this repository already lints and authors, which bounds what a candidate can be - `%(TASKLESS_CLI)s agent check`: validate newly created rules against the codebase +- `%(TASKLESS_CLI)s agent ci`: run the rules in CI once they exist +- `%(TASKLESS_CLI)s agent hooks`: run the rules on staged files before each commit - `%(TASKLESS_CLI)s agent info`: inspect the current `.taskless/taskless.json` state diff --git a/packages/cli/src/commands/detect.ts b/packages/cli/src/commands/detect.ts index 79d6e608..acd7b1d8 100644 --- a/packages/cli/src/commands/detect.ts +++ b/packages/cli/src/commands/detect.ts @@ -11,7 +11,7 @@ export const detectCommand = defineCommand({ meta: { name: "detect", description: - "Scan the repo for configured linters, languages, and existing rule styles (offline, deterministic)", + "Scan the repo for configured linters, languages, rule styles, CI, and commit hooks (offline, deterministic)", }, args: { dir: { @@ -74,5 +74,23 @@ export const detectCommand = defineCommand({ console.log(` ${style.source}: ${style.description}`); } } + + if (result.ci.length === 0) { + console.log("\nCI: none detected"); + } else { + console.log("\nCI:"); + for (const system of result.ci) { + console.log(` ${system.name}: ${system.evidence.join(", ")}`); + } + } + + if (result.hooks.length === 0) { + console.log("\nCommit hooks: none detected"); + } else { + console.log("\nCommit hooks:"); + for (const tool of result.hooks) { + console.log(` ${tool.name}: ${tool.evidence.join(", ")}`); + } + } }, }); diff --git a/packages/cli/src/detect/automation.ts b/packages/cli/src/detect/automation.ts new file mode 100644 index 00000000..8891d384 --- /dev/null +++ b/packages/cli/src/detect/automation.ts @@ -0,0 +1,185 @@ +import { existsSync, globSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +/** + * Something that runs commands on the repository's behalf without anyone + * typing them: a CI system, or a tool that runs at commit time. Same shape as a + * detected linter, so a consumer handling one handles all three. + */ +export interface DetectedAutomation { + /** Identifier, e.g. `github-actions` or `husky`. */ + name: string; + /** What matched: a path relative to the scan root, or a `package.json` marker. */ + evidence: string[]; +} + +/** + * Matched at the scan ROOT only, unlike linters. A CI system reads its config + * from the repository root and git runs one set of hooks per repository, so a + * `.gitlab-ci.yml` three directories down is a fixture or a vendored project, + * not this repository's CI. The monorepo walk that is right for linter configs + * would report it anyway. + * + * `paths` are globs relative to the root. A trailing `/` names a directory, + * whose presence is the signal (`.husky/` holds the hook scripts themselves). + */ +interface AutomationSignal { + name: string; + paths?: string[]; + /** Root `package.json` dependency names. */ + deps?: string[]; + /** Root `package.json` top-level keys the tool reads its config from. */ + packageJsonKeys?: string[]; +} + +/** The file table the `ci` recipe uses, as root-relative globs. */ +const CI_SIGNALS: readonly AutomationSignal[] = [ + { + name: "github-actions", + paths: [".github/workflows/*.yml", ".github/workflows/*.yaml"], + }, + { name: "gitlab-ci", paths: [".gitlab-ci.yml"] }, + { name: "circleci", paths: [".circleci/config.yml"] }, + { name: "jenkins", paths: ["Jenkinsfile"] }, + { + name: "azure-pipelines", + paths: ["azure-pipelines.yml", "azure-pipelines.yaml"], + }, + { name: "bitbucket-pipelines", paths: ["bitbucket-pipelines.yml"] }, + { name: "buildkite", paths: [".buildkite/"] }, + { name: "drone", paths: [".drone.yml"] }, + { name: "travis-ci", paths: [".travis.yml"] }, +]; + +/** + * Tools that run commands at commit time. + * + * lint-staged is not a hook manager; something else has to call it. It is + * listed because it is what turns "run on commit" into "run on the staged + * files", which is the question the onboard recipe asks, and leaving it out + * would hide the most common way a repository already answers it. + */ +const HOOK_SIGNALS: readonly AutomationSignal[] = [ + { name: "husky", paths: [".husky/"], deps: ["husky"] }, + { + name: "lefthook", + paths: [ + "lefthook.yml", + "lefthook.yaml", + "lefthook.json", + "lefthook.toml", + ".lefthook.yml", + ".lefthook.yaml", + ".lefthook.json", + ".lefthook.toml", + ], + deps: ["lefthook", "@evilmartians/lefthook"], + }, + { name: "pre-commit", paths: [".pre-commit-config.yaml"] }, + { + name: "simple-git-hooks", + paths: [ + ".simple-git-hooks.json", + ".simple-git-hooks.js", + ".simple-git-hooks.cjs", + ".simple-git-hooks.mjs", + "simple-git-hooks.json", + "simple-git-hooks.js", + "simple-git-hooks.cjs", + "simple-git-hooks.mjs", + ], + deps: ["simple-git-hooks"], + packageJsonKeys: ["simple-git-hooks"], + }, + { + name: "lint-staged", + paths: [ + ".lintstagedrc", + ".lintstagedrc.json", + ".lintstagedrc.yaml", + ".lintstagedrc.yml", + ".lintstagedrc.mjs", + ".lintstagedrc.cjs", + ".lintstagedrc.js", + "lint-staged.config.mjs", + "lint-staged.config.cjs", + "lint-staged.config.js", + ], + deps: ["lint-staged"], + packageJsonKeys: ["lint-staged"], + }, +]; + +interface RootPackageJson { + keys: Set; + deps: Set; +} + +function objectKeys(value: unknown): string[] { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return []; + } + return Object.keys(value as Record); +} + +/** The root `package.json`'s keys and dependency names; empty when absent or malformed. */ +async function readRootPackageJson(root: string): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(resolve(root, "package.json"), "utf8")); + } catch { + return { keys: new Set(), deps: new Set() }; + } + const record = (parsed ?? {}) as Record; + return { + keys: new Set(objectKeys(parsed)), + deps: new Set([ + ...objectKeys(record.dependencies), + ...objectKeys(record.devDependencies), + ]), + }; +} + +function matchSignals( + root: string, + signals: readonly AutomationSignal[], + packageJson: RootPackageJson +): DetectedAutomation[] { + const detected: DetectedAutomation[] = []; + for (const signal of signals) { + const evidence: string[] = []; + for (const pattern of signal.paths ?? []) { + if (pattern.endsWith("/")) { + if (existsSync(resolve(root, pattern))) evidence.push(pattern); + } else { + evidence.push(...globSync(pattern, { cwd: root }).toSorted()); + } + } + for (const key of signal.packageJsonKeys ?? []) { + if (packageJson.keys.has(key)) evidence.push(`package.json (${key})`); + } + for (const dep of signal.deps ?? []) { + if (packageJson.deps.has(dep)) { + evidence.push(`dependency ${dep} (package.json)`); + } + } + if (evidence.length > 0) detected.push({ name: signal.name, evidence }); + } + return detected; +} + +/** + * The CI systems and commit-time tools configured at `root`. Pure filesystem + * reads; an unreadable or malformed `package.json` contributes nothing rather + * than failing the scan. + */ +export async function detectAutomation( + root: string +): Promise<{ ci: DetectedAutomation[]; hooks: DetectedAutomation[] }> { + const packageJson = await readRootPackageJson(root); + return { + ci: matchSignals(root, CI_SIGNALS, packageJson), + hooks: matchSignals(root, HOOK_SIGNALS, packageJson), + }; +} diff --git a/packages/cli/src/detect/scan.ts b/packages/cli/src/detect/scan.ts index 5c0d1a26..1e7528e9 100644 --- a/packages/cli/src/detect/scan.ts +++ b/packages/cli/src/detect/scan.ts @@ -5,6 +5,7 @@ import { resolve } from "node:path"; import { parse as parseToml } from "smol-toml"; import { RULES_DIRECTORY } from "../rules/layout"; +import { detectAutomation, type DetectedAutomation } from "./automation"; export interface DetectedLinter { name: string; @@ -27,6 +28,10 @@ export interface DetectResult { linters: DetectedLinter[]; languages: string[]; ruleStyles: RuleStyle[]; + /** CI systems configured at the scan root. */ + ci: DetectedAutomation[]; + /** Tools that run commands at commit time, configured at the scan root. */ + hooks: DetectedAutomation[]; } /** @@ -501,7 +506,8 @@ function detectRuleStyles( * list + depth cap) finds manifests and configs anywhere in the tree, so a * linter configured in a sub-package is detected with its path as evidence. The * flow is languages → linters: a linter's dependency is looked up only in its - * own language's manifests. + * own language's manifests. CI systems and commit-time tools are the exception + * to the walk, read at the root only (see `./automation`). */ export async function detectRepository(cwd: string): Promise { const root = resolve(cwd); @@ -633,5 +639,6 @@ export async function detectRepository(cwd: string): Promise { linters, languages: [...languages], ruleStyles: detectRuleStyles(root, nodeManifests), + ...(await detectAutomation(root)), }; } diff --git a/packages/cli/src/prompts/index.ts b/packages/cli/src/prompts/index.ts index a4f7e489..9528b5f1 100644 --- a/packages/cli/src/prompts/index.ts +++ b/packages/cli/src/prompts/index.ts @@ -94,6 +94,7 @@ export const INTERNAL_TOPICS = [ "detect", "feedback", "feedback-invite", + "hooks", "improve-rule", "info", "init", diff --git a/packages/cli/src/schemas/detect.ts b/packages/cli/src/schemas/detect.ts index 1fd59e76..531dbded 100644 --- a/packages/cli/src/schemas/detect.ts +++ b/packages/cli/src/schemas/detect.ts @@ -20,6 +20,18 @@ const ruleStyleSchema = z.object({ .describe("How the repo authors rules of this kind, for downstream reuse"), }); +/** A CI system or commit-time tool configured at the scan root */ +const detectedAutomationSchema = z.object({ + name: z + .string() + .describe("Identifier, e.g. github-actions, gitlab-ci, husky, lint-staged"), + evidence: z + .array(z.string()) + .describe( + "What matched: a path relative to the scan root, or a package.json key or dependency marker" + ), +}); + /** Output schema for `taskless detect --json` on success */ export const outputSchema = z.object({ success: z.literal(true), @@ -32,6 +44,14 @@ export const outputSchema = z.object({ ruleStyles: z .array(ruleStyleSchema) .describe("Styles of the repo's own existing rules"), + ci: z + .array(detectedAutomationSchema) + .describe("CI systems configured at the scan root"), + hooks: z + .array(detectedAutomationSchema) + .describe( + "Tools that run commands at commit time (hook managers and lint-staged), configured at the scan root" + ), }); // On the (internal-only) error path `detect` emits the standard diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts index 7fc757d3..cef66282 100644 --- a/packages/cli/test/detect.test.ts +++ b/packages/cli/test/detect.test.ts @@ -48,6 +48,8 @@ interface DetectJson { linters: { name: string; evidence: string[] }[]; languages: string[]; ruleStyles: { source: string; description: string }[]; + ci: { name: string; evidence: string[] }[]; + hooks: { name: string; evidence: string[] }[]; } async function detect(cwd: string): Promise { @@ -268,7 +270,14 @@ describe("taskless detect", () => { await writeFile(join(cwd, ".eslintrc.json"), "{}", "utf8"); const result = await detect(cwd); expect(Object.keys(result).toSorted()).toEqual( - ["languages", "linters", "ruleStyles", "success"].toSorted() + [ + "ci", + "hooks", + "languages", + "linters", + "ruleStyles", + "success", + ].toSorted() ); // A linter entry exposes only name + evidence, never a rule-name claim. for (const linter of result.linters) { @@ -317,4 +326,106 @@ describe("taskless detect", () => { expect(result.success).toBe(true); expect(result.linters).toEqual([]); }); + + describe("CI systems and commit hooks", () => { + it("reports both as empty arrays on a bare repository", async () => { + const result = await detect(cwd); + expect(result.ci).toEqual([]); + expect(result.hooks).toEqual([]); + }); + + it("reports each GitHub Actions workflow file as evidence", async () => { + await mkdir(join(cwd, ".github", "workflows"), { recursive: true }); + await writeFile(join(cwd, ".github/workflows/test.yml"), "", "utf8"); + await writeFile(join(cwd, ".github/workflows/release.yaml"), "", "utf8"); + const result = await detect(cwd); + expect(result.ci).toEqual([ + { + name: "github-actions", + evidence: [ + ".github/workflows/test.yml", + ".github/workflows/release.yaml", + ], + }, + ]); + }); + + it.each([ + ["gitlab-ci", ".gitlab-ci.yml"], + ["circleci", ".circleci/config.yml"], + ["jenkins", "Jenkinsfile"], + ["azure-pipelines", "azure-pipelines.yml"], + ["bitbucket-pipelines", "bitbucket-pipelines.yml"], + ["drone", ".drone.yml"], + ["travis-ci", ".travis.yml"], + ])("detects %s from %s", async (name, file) => { + await mkdir(join(cwd, file, ".."), { recursive: true }); + await writeFile(join(cwd, file), "", "utf8"); + const result = await detect(cwd); + expect(result.ci).toEqual([{ name, evidence: [file] }]); + }); + + it("detects buildkite from its directory", async () => { + await mkdir(join(cwd, ".buildkite")); + const result = await detect(cwd); + expect(result.ci).toEqual([ + { name: "buildkite", evidence: [".buildkite/"] }, + ]); + }); + + it("does not report a CI config below the scan root", async () => { + await mkdir(join(cwd, "packages", "api"), { recursive: true }); + await writeFile(join(cwd, "packages/api/.gitlab-ci.yml"), "", "utf8"); + const result = await detect(cwd); + expect(result.ci).toEqual([]); + }); + + it("detects husky and lint-staged from the root package.json", async () => { + await mkdir(join(cwd, ".husky")); + await writeFile( + join(cwd, "package.json"), + JSON.stringify({ + devDependencies: { husky: "^9.0.0", "lint-staged": "^15.0.0" }, + "lint-staged": { "*": "prettier --check" }, + }), + "utf8" + ); + const result = await detect(cwd); + expect(result.hooks).toEqual([ + { + name: "husky", + evidence: [".husky/", "dependency husky (package.json)"], + }, + { + name: "lint-staged", + evidence: [ + "package.json (lint-staged)", + "dependency lint-staged (package.json)", + ], + }, + ]); + }); + + it.each([ + ["lefthook", "lefthook.yml"], + ["pre-commit", ".pre-commit-config.yaml"], + ["simple-git-hooks", ".simple-git-hooks.json"], + ["lint-staged", ".lintstagedrc.json"], + ])("detects %s from %s", async (name, file) => { + await writeFile(join(cwd, file), "", "utf8"); + const result = await detect(cwd); + expect(result.hooks).toEqual([{ name, evidence: [file] }]); + }); + + it("does not report a hook tool named only by a sub-package", async () => { + await mkdir(join(cwd, "packages", "web"), { recursive: true }); + await writeFile( + join(cwd, "packages/web/package.json"), + JSON.stringify({ devDependencies: { husky: "^9.0.0" } }), + "utf8" + ); + const result = await detect(cwd); + expect(result.hooks).toEqual([]); + }); + }); }); diff --git a/packages/cli/test/onboard.test.ts b/packages/cli/test/onboard.test.ts index 9b647770..a024f3ad 100644 --- a/packages/cli/test/onboard.test.ts +++ b/packages/cli/test/onboard.test.ts @@ -316,6 +316,71 @@ describe("onboard recipe establishes the routing surface first", () => { }); }); +// #441: the recipe went from materializing rules straight to the +// consent-gated mark-complete question, so a project could finish onboarding +// with rules nothing runs. The agent that improvised the missing question put +// it in the same message as mark-complete, and the user's "yes" could have +// been read as an answer to either. +describe("onboard recipe decides when the rules run", () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "taskless-onboard-when-")); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + it("puts the step between materializing and marking complete", async () => { + const { stdout } = await runCli(["agent", "onboard", "-d", cwd], cwd); + + const materialize = stdout.indexOf("Offer materialization per bullet"); + const decide = stdout.indexOf("Decide when the rules run"); + const markComplete = stdout.indexOf( + "Ask before marking onboarding complete" + ); + + expect(materialize).toBeGreaterThan(-1); + expect(decide).toBeGreaterThan(materialize); + expect(markComplete).toBeGreaterThan(decide); + }); + + it("offers CI and a hook from what detect reported", async () => { + const { stdout } = await runCli(["agent", "onboard", "-d", cwd], cwd); + const step = stdout.slice( + stdout.indexOf("Decide when the rules run"), + stdout.indexOf("Ask before marking onboarding complete") + ); + + expect(step).toContain("`ci` and `hooks` fields"); + expect(step).toMatch(/agent ci`/); + expect(step).toMatch(/agent hooks`/); + // Declining both is an answer, not a stall. + expect(step).toContain("neither"); + }); + + it("asks the mark-complete question on its own", async () => { + const { stdout } = await runCli(["agent", "onboard", "-d", cwd], cwd); + const step = stdout.slice( + stdout.indexOf("Ask before marking onboarding complete") + ); + + // Whitespace collapsed so a rewrap of the recipe does not fail the test. + expect(step.replaceAll(/\s+/g, " ")).toContain( + "Ask it in a message with no other question in it." + ); + }); + + it("names both topics in See Also", async () => { + const { stdout } = await runCli(["agent", "onboard", "-d", cwd], cwd); + const seeAlso = stdout.slice(stdout.indexOf("## See Also")); + + expect(seeAlso).toMatch(/agent ci`/); + expect(seeAlso).toMatch(/agent hooks`/); + }); +}); + // #393: the recipe now carries passages conditioned on what the host has on // `PATH` and on whether the repository is on GitHub. `onboard` is not a topic // `agent` dispatches to a shared detection step — each command detects for diff --git a/skills/taskless/SKILL.md b/skills/taskless/SKILL.md index 2ca96e35..41096a07 100644 --- a/skills/taskless/SKILL.md +++ b/skills/taskless/SKILL.md @@ -67,6 +67,7 @@ linter, the `create-legacy-rule` path needs nothing installed. | Check code against rules | `%(TASKLESS_CLI)s agent check` | | Log in, log out, or status | `%(TASKLESS_CLI)s agent auth` | | Wire into CI | `%(TASKLESS_CLI)s agent ci` | +| Run checks before commits | `%(TASKLESS_CLI)s agent hooks` | Two of those rows look alike and are not. Running `%(TASKLESS_CLI)s` migrates the `.taskless/` layout and refreshes the installed skills: that is