From 5fcda85f156161dc074b7e5aada8357bd205e219 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 13 Oct 2026 17:26:38 -0700 Subject: [PATCH 1/3] docs(openspec): propose streamlined-onboarding-report One-question onboarding that always runs logged out, a taskless report command that publishes a redacted ReportV1 to tskl.es, and detect reporting ast-grep, Vale, host tools, and agent hints. Stacked, merging forward. --- .changeset/streamlined-onboarding-report.md | 5 + .../.openspec.yaml | 2 + .../streamlined-onboarding-report/design.md | 112 +++++++++ .../streamlined-onboarding-report/proposal.md | 51 ++++ .../specs/cli-agent/spec.md | 20 ++ .../specs/cli-detect/spec.md | 195 +++++++++++++++ .../specs/cli-feedback-survey/spec.md | 79 ++++++ .../specs/cli-onboard/spec.md | 120 +++++++++ .../specs/cli-report/spec.md | 229 ++++++++++++++++++ .../specs/cli-taskless-bootstrap/spec.md | 63 +++++ .../specs/skill-taskless/spec.md | 15 ++ .../streamlined-onboarding-report/tasks.md | 44 ++++ 12 files changed, 935 insertions(+) create mode 100644 .changeset/streamlined-onboarding-report.md create mode 100644 openspec/changes/streamlined-onboarding-report/.openspec.yaml create mode 100644 openspec/changes/streamlined-onboarding-report/design.md create mode 100644 openspec/changes/streamlined-onboarding-report/proposal.md create mode 100644 openspec/changes/streamlined-onboarding-report/specs/cli-agent/spec.md create mode 100644 openspec/changes/streamlined-onboarding-report/specs/cli-detect/spec.md create mode 100644 openspec/changes/streamlined-onboarding-report/specs/cli-feedback-survey/spec.md create mode 100644 openspec/changes/streamlined-onboarding-report/specs/cli-onboard/spec.md create mode 100644 openspec/changes/streamlined-onboarding-report/specs/cli-report/spec.md create mode 100644 openspec/changes/streamlined-onboarding-report/specs/cli-taskless-bootstrap/spec.md create mode 100644 openspec/changes/streamlined-onboarding-report/specs/skill-taskless/spec.md create mode 100644 openspec/changes/streamlined-onboarding-report/tasks.md diff --git a/.changeset/streamlined-onboarding-report.md b/.changeset/streamlined-onboarding-report.md new file mode 100644 index 00000000..6805300c --- /dev/null +++ b/.changeset/streamlined-onboarding-report.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +`taskless detect --json` now reports ast-grep and Vale configurations as linters (Taskless's own configuration under `.taskless/` excluded), the command-line tools on the host's `PATH` (`gh`, `git`, `jq`, and now `glab` for GitLab repositories) under `hostTools`, and an `agent` section of commands an agent may choose to run to check whether a source is usable, such as `gh auth status`. Detection still runs nothing and stays offline. diff --git a/openspec/changes/streamlined-onboarding-report/.openspec.yaml b/openspec/changes/streamlined-onboarding-report/.openspec.yaml new file mode 100644 index 00000000..5fcc9eca --- /dev/null +++ b/openspec/changes/streamlined-onboarding-report/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-13 diff --git a/openspec/changes/streamlined-onboarding-report/design.md b/openspec/changes/streamlined-onboarding-report/design.md new file mode 100644 index 00000000..1db7a565 --- /dev/null +++ b/openspec/changes/streamlined-onboarding-report/design.md @@ -0,0 +1,112 @@ +## Context + +See proposal.md for motivation. The constraints that shape the approach: + +- **Recipes are embedded at build time** and served by two paths for onboarding (`taskless onboard`, `taskless agent onboard`), which must agree byte for byte in every host-tool state. Recipes are also published through `@taskless/cli/prompts`, whose graph may reach no node builtin, so host state is passed in as a render option and never read ambiently. +- **Host detection looks, never runs.** `packages/cli/src/detect/host-tools.ts` establishes a tool by finding a file on `PATH`, and `cli-detect` requires detect to work offline and logged out. Both properties survive this change: sign-in checks become hints for the agent, not something detect does. +- **The ReportV1 contract belongs to tskl.es.** It is published as JSON Schema at `https://tskl.es/api/v1/schema` with an `x-taskless` block describing publish: `POST /api/v1/reports`, 256 KiB cap, 10/min/IP, retry only on 502 and never after a timeout. Every object rejects unknown keys. tskl.es does not redact. +- **The cloud API schema is already vendored** by `scripts/fetch-api-schema.ts` (committed document, no timestamp, fail loudly on a failed fetch). The report schema follows that precedent. +- **The name `report` is taken internally**: `packages/cli/src/rules/report.ts` is the summary `check` sends to reconcile. + +## Goals / Non-Goals + +**Goals:** + +- One CLI-owned path from a repository to a published report, where every number the page shows is either measured by the CLI or explicitly an agent estimate. +- The CLI, not the recipe, enforces the gate, redaction, validation, and the retry contract, so a skimmed recipe cannot publish something bad. +- Regenerating a report later is the same recipe as the first one. + +**Non-Goals:** + +- `public` retention. Every report publishes `private` (unlisted, 30 days). Choosing public is a later decision with its own consent story. +- Authoring runtime rules during onboarding, or logging in. +- Detecting `.cmd`-wrapped tools on Windows (see Risks). +- GitLab scopes in the report. ReportV1 knows only `github` and `none`; a GitLab repository reports as `none`. +- A `linear` or `acli` host tool. Both names collide with unrelated tools (several `linear` CLIs; Acquia CLI installs as `acli`), and their MCPs are the more common route. + +## Decisions + +### D1. The agent hands over facts, and the CLI builds the report + +`report --from` takes a facts file, not a ReportV1. The agent supplies only what it alone knows (history, window, per-rule titles, origin and fix counts, legacy and cloud entries, two estimates, its redaction counts and withholdings). The CLI owns everything else and wins any conflict. + +_Alternative:_ the agent edits the draft into a full ReportV1. Rejected: it puts the projection arithmetic in a prompt, lets the agent overwrite facts the CLI measured, and makes the facts schema and ReportV1 the same thing, so a tskl.es schema change would break the recipe instead of one merge function. + +### D2. The draft lists what is missing + +`taskless report` prints the CLI's half plus `missing[]`. The recipe says "supply what `missing` names" rather than enumerating fields, so the facts schema and the recipe cannot drift. + +### D3. Projection arithmetic + +Per tier, over a facts-declared window of `d` days: + +``` +commentsPrevented[tier] = round(Σ rule.origin.reviewComments × 30 / d) +agentCatches[tier] = round(Σ rule.fixed × 30 / d) +``` + +`contextTokens` and `callsPerCatch` are agent estimates from this user's session habits (how large their sessions run, how many extra turns a missed issue costs). tskl.es exposes them as sliders, so the published page lets a reader adjust them, and turns them into its token figures itself. The other inputs (`cloudComplexity`, `pricePerMillionTokens`, `minutesPerComment`, `reviewerHourlyUsd`) are CLI constants, matching the schema's example values until there is a reason to differ. + +The gate is `≥ 3` Taskless rules: `cli` tier, or `legacy` rules the agent wrote into the repository's own linter, with tests neither `fail` nor `refused`. Cloud candidates are carried in addition and never count. The projection does not gate: the page lets a reader adjust its inputs, so a threshold on it would gate on a number nobody sees. The gate lives in the CLI so it has one home. + +### D4. Tiers and engines + +| Source | tier | engine | tests | +| --------------------------------------------------------------------- | -------- | -------------------------------- | -------------------------------------------- | +| `.taskless/rules/sg/` | `cli` | `sg` | from `.tests/`, CLI-run | +| `.taskless/rules/vale/` | `cli` | `vale` | from `.tests/`, CLI-run | +| `.taskless/rules/runtime/` (existing, not authored by onboarding) | `cloud` | `runtime` | `not-run` (logged out) | +| facts `legacy[]` (rule written into the repo's own linter) | `legacy` | omitted; `tool` names the linter | `not-run` unless the agent supplies fixtures | +| facts `cloud[]` (runtime candidate, not authored) | `cloud` | `runtime` | `not-run`; `complexity` from the agent | + +Fixture code is the first failing and first passing case from the rule's tests, subject to D5. + +### D5. Redaction: withhold, never mask + +The CLI scans each fixture and every agent-supplied string for five pattern classes (`secret`, `email`, `host`, `ip`, `phone`). A fixture hit withholds the whole fixture, because a half-masked snippet reads as broken code on a page meant to show the rule working. A hit in an agent-supplied string (a title, a label) refuses with `REPORT_REDACTION`, because the CLI cannot rewrite prose and the agent can. `person`, `customer`, and `text` can only be judged by the agent, which declares its counts; the CLI adds its own per category. `policyVersion` is a CLI constant, bumped whenever the patterns change. + +_Alternative:_ mask matches in place. Rejected for fixtures as above, and because a masked secret still leaks its shape and length. + +### D6. Vendoring and validating ReportV1 + +A `scripts/fetch-report-schema.ts` writes `src/generated/report-v1.schema.json` in the style of `fetch-api-schema.ts` (no timestamp, prettier-formatted, fail loudly). It is not on the build path. + +For validation, the decision is zod 4's `z.fromJSONSchema()` over the vendored document (zod 4.3.6 is already a dependency and exports it), giving one schema used for both validation and the inferred type. Task 3.1 confirms it handles this document (the `oneOf` scope, `const`, regex `pattern`s, `format`) with a test over the schema's own example and counter-examples. A gap there is a bug to fix or report against zod, not a reason to hand-write the schema. No new dependency. + +### D7. Publish client + +`fetch` with a timeout. A 502 is retried at most twice with short backoff. A timeout or network error is never retried and becomes `REPORT_PUBLISH_UNKNOWN`, because tskl.es says a timed-out publish may have succeeded. A 429 is surfaced, not retried: the limit is per IP and a loop would only extend it. The URL is a constant overridable by an environment variable for tests, mirroring how the API base URL is handled. + +### D8. The record and the delete credential + +`.taskless/reports/.json` holds `{ url, deleteUrl, expiresAt }` exactly as returned. The delete credential is the `deleteUrl` tskl.es returns once; the user's "delete key" is that value. It never reaches stdout or telemetry, and the directory is gitignored by migration 11. `report delete` reads the record and sends an HTTP `DELETE` to `deleteUrl`, the URL tskl.es returns for exactly that. + +### D9. Detect: one table for tools, presence, and hints + +`host-tools.ts` gains a single table, one row per tool: name, applicability predicate, inapplicable reason, hint command, and hint text. `detect --json`, the `agent` hints, and the onboard recipe's host-tool passages all render from it. `glab`'s applicability comes from the same remote resolution as `gh`'s, extended to recognize a GitLab host. Its hint states that `glab auth status` can exit 0 when signed out (gitlab-org/cli#911), so the agent reads the output. + +ast-grep (`sgconfig.yml`, `.sgconfig.yml`) and Vale (`.vale.ini`, `_vale.ini`) join the linter signals, with `.taskless/` pruned from their evidence. + +### D10. Onboard v6 and the survey + +Onboarding loses its survey invite (`onboard` leaves the surveyed set in `survey/constants.ts`) and instead tells the agent to follow `rule-feedback` unprompted. `rule-feedback` gains a short "reached from onboarding" branch: no `verbatim`, no question, send. `feedback send` already refuses under the telemetry opt-out, so nothing new is needed there. + +The CI/hooks step moves to the very end. It is the one question after the opening yes, asked once the user has seen the report, and the recipe strongly encourages a yes: checks in CI are what keep agents from reintroducing what the rules catch. Doing it unasked would write CI config the user did not request. A no gets the `hooks` topic named in one line as the local alternative. + +### D11. Rename before build + +`rules/report.ts` → `rules/check-summary.ts` (with its importers: `plan-check.ts`, `recover.ts`, `verdicts.ts`, two tests) lands as its own PR, so the largest diff is not also carrying a rename. + +## Risks / Trade-offs + +- **[A report can publish with a small projection]** → Accepted. Three working Taskless rules justify a report on their own, and the reader can adjust the estimates on the page. +- **[Agent estimates drive the headline]** → `fixedBasis` distinguishes measured from estimated per rule, and the page's sliders let a reader correct the two estimates. The recipe instructs the agent to estimate from observed session behavior, not optimism. +- **[Pattern redaction misses things]** → tskl.es does not redact, so a miss is published. Mitigations: withhold-not-mask, the agent's own pass declared in the facts, `private` retention, a recorded delete credential, and the recipe telling the agent to withhold anything it cannot vouch for. +- **[A timeout may have published]** → `REPORT_PUBLISH_UNKNOWN` says so plainly. No record is written, so the possible report cannot be deleted from the CLI; it expires in 30 days. +- **[tskl.es schema drift]** → A vendored schema that falls behind makes the CLI refuse a payload tskl.es would accept, or send one it rejects. The refresh script makes the drift a reviewable diff, and a 400 surfaces tskl.es's own details. +- **[Windows `.cmd` tools]** → `executableName()` appends only `.exe` and `findOnPath` ignores `PATHEXT`. Every tool detected today ships an `.exe`, so nothing is missed now; a future `.cmd`-only tool (for example `az`) would be silently reported absent. Recorded, not fixed. +- **[A CI offer after a long flow is easy to skip]** → It comes last, after the report has shown the value, and the recipe tells the agent to make the case for it rather than just mention it. + +## Migration Plan + +Migration 11 runs on the next bootstrap of any existing `.taskless/`, creating `reports/` and its ignore line. Nothing else migrates. Rolling back the CLI leaves an unused, ignored `reports/` directory behind, which is harmless; older CLIs refuse a newer scaffold through the existing version check unless `--allow-version-mismatches` is passed. diff --git a/openspec/changes/streamlined-onboarding-report/proposal.md b/openspec/changes/streamlined-onboarding-report/proposal.md new file mode 100644 index 00000000..ebe7e961 --- /dev/null +++ b/openspec/changes/streamlined-onboarding-report/proposal.md @@ -0,0 +1,51 @@ +## Why + +Onboarding is a conversation today: the agent asks which sources to mine, asks again for every candidate it found, asks when the rules should run, and asks before marking anything complete. Every one of those turns is a place a fresh user stops, and the flow ends with rules on disk but nothing that shows what they are worth. We want onboarding to be one yes followed by a finished result, and that result to be something a user can look at and share: a report on tskl.es showing what Taskless found and what it will catch. + +## What Changes + +- **Onboarding asks one question.** The recipe opens with "Do you want me to set up Taskless for you?", set apart by blank lines and put through the host's structured question tool when it has one. That yes covers mining, writing rules, publishing the report, and sending onboarding feedback. The per-source menu, per-bullet materialization offer, and the separate mark-complete confirmation are removed from the flow. The CI decision moves to the very end, as a strongly encouraged offer to wire `taskless check` into CI, because checks in CI are what keep agents from reintroducing what the rules catch. **BREAKING** for the recipe's contract (topic v5 → v6); no CLI surface is removed. +- **Onboarding is always anonymous.** It authors only rules the CLI can write logged out (`legacy`, `sg`, `vale`). Candidates the agent judges are better solved by a runtime rule are not authored; they go into the report as `cloud` tier. +- **Clarifying questions only when stuck.** The agent asks the user anything further only when it is unsure what rules to write AND the report gate refuses for fewer than three Taskless rules (`sg`, `vale`, or `legacy` rules it wrote). Cloud-tier candidates are carried in addition and do not count. +- **Onboarding completes itself.** The agent runs `onboard --mark-complete` once the rules are written, without a confirmation question, and then runs the report recipe. In parallel it sends onboarding feedback through `rule-feedback`, answering as the respondent. `onboard` no longer carries the survey invite. +- **New `taskless report` command.** With no flags it prints a draft: everything the CLI can know about this repository's rules, plus `missing[]`, the facts only the agent can supply. `taskless report --from ` takes the agent's facts, computes the projection, enforces the gate, redacts, validates against the vendored ReportV1 schema, publishes to `https://tskl.es/api/v1/reports`, records the result under `.taskless/reports/`, and prints a brief templated rule summary with the link. `taskless report delete` removes a published report using the recorded delete credential. +- **New `report` agent topic**: check what exists, supply what is missing, generate, delete on request. Onboarding hands off to it, and it serves "regenerate my Taskless report" later. +- **ReportV1 is vendored** from `https://tskl.es/api/v1/schema` by a generate script, the same way the cloud API schema is. +- **Migration 11** creates `.taskless/reports/` and adds `/reports/` to `.taskless/.gitignore`, since each record holds a credential that deletes a page anyone with the link can view. +- **`detect` reports more.** ast-grep and Vale join the linter signals (configs under `.taskless/` are Taskless's own and are skipped). `detect --json` gains the host tools (`gh`, `git`, `jq`, and new `glab`) and an `agent` hints section naming commands the agent may choose to run, such as `gh auth status`. Detect still executes nothing and stays offline. +- **Rename** `packages/cli/src/rules/report.ts` to `rules/check-summary.ts`, which is what it is (the `check`→reconcile summary), so "report" means one thing in this codebase. +- **The skill** routes "make / regenerate a Taskless report" to the `report` topic. + +## Capabilities + +### New Capabilities + +- `cli-report`: the `taskless report` command (draft, `--from` publish, `delete`), the facts file the agent supplies, projection and gate, redaction, schema vendoring and validation, the publish client and its retry policy, the `.taskless/reports/` record, the templated summary, and the `report` agent topic. + +### Modified Capabilities + +- `cli-onboard`: the recipe requirement is rewritten for the one-question, always-anonymous, self-completing flow and topic v6; the conversational menu, per-bullet offers, and consent-gated mark-complete are removed from it, and the CI offer moves to the end. +- `cli-detect`: "Detect scans deterministic repo signals only" is restated to cover ast-grep and Vale signals, the `.taskless/` exclusion, and host tools reported by presence; "Detect emits a stable JSON shape" gains the `hostTools` and `agent` fields. +- `cli-taskless-bootstrap`: the `install.onboarded` semantics now record the user's yes to onboarding rather than a separate confirmation (the field is still written only by `--mark-complete`); adds migration 11. +- `cli-feedback-survey`: `onboard` leaves the surveyed recipes; onboarding feedback is sent by the agent unprompted, still honoring the telemetry opt-out. +- `cli-agent`: registers the `report` topic. +- `skill-taskless`: the router's topic table gains a `report` row. + +## Delivery Shape + +**Stacked, merging forward.** Each unit is safe in production on its own: detect's new fields are additive, the rename changes no behavior, and `taskless report` is inert until a recipe calls it. Only the onboard rewrite depends on the units below it, and it is the last to land. + +1. Detect: ast-grep/Vale signals, `glab`, host tools and `agent` hints in `--json`. The changeset lands here, grown by each later PR. +2. Rename `rules/report.ts` → `rules/check-summary.ts`. +3. `taskless report`, schema vendoring, migration 11, the `report` topic. +4. Onboard topic v6, skill row, feedback interplay, automatic mark-complete. +5. Archive this change. + +Diffs 3 and 4 may pass ~1200 lines; that is accepted for this change, since the report command's parts (merge, gate, redaction, publish) are only reviewable together. The package is pre-1.0, so the bump is `patch`. + +## Impact + +- **Code**: `packages/cli/src/commands/` (new `report.ts`, `onboard.ts`), `packages/cli/src/agent/` (`onboard.md` v6, new `report.md`, `rule-feedback.md`), `packages/cli/src/detect/` (`scan.ts`, `host-tools.ts`, detect schema), `packages/cli/src/survey/constants.ts`, `packages/cli/src/filesystem/migrations/` (new 0011), `packages/cli/src/rules/report.ts` (renamed), `packages/cli/scripts/` (new schema fetch), `packages/cli/src/generated/` (vendored ReportV1), the skill and `tskl` command bodies, `packages/cli/src/prompts/` (topic classification). +- **Network**: a new outbound call to `tskl.es` (publish and delete), separate from the Taskless API and requiring no auth. +- **Privacy**: rule fixtures leave the machine on a page viewable by anyone with the link. tskl.es performs no redaction of its own; the agent and the CLI are solely responsible, and withhold rather than partially mask. +- **External dependency**: `report delete` sends an HTTP `DELETE` to the `deleteUrl` tskl.es returns on publish. The tskl.es side is adding that support; the schema's `x-taskless` block does not describe it yet. diff --git a/openspec/changes/streamlined-onboarding-report/specs/cli-agent/spec.md b/openspec/changes/streamlined-onboarding-report/specs/cli-agent/spec.md new file mode 100644 index 00000000..f74ca3c6 --- /dev/null +++ b/openspec/changes/streamlined-onboarding-report/specs/cli-agent/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: report topic is registered in the agent index + +An agent topic `report` SHALL be registered. The CLI SHALL embed `packages/cli/src/agent/report.md` at build time through the same mechanism as every other topic. `taskless agent report` SHALL print the rendered recipe, and the topic SHALL appear in the output of `taskless agent` with a one-line summary describing it as publishing or regenerating a Taskless report. Fetching it SHALL emit `cli_agent` with `report` as its `topic` property. + +#### Scenario: The report topic returns the recipe + +- **WHEN** a user runs `taskless agent report` +- **THEN** the CLI SHALL print the `report` recipe and exit 0 + +#### Scenario: Topic index includes report + +- **WHEN** a user runs `taskless agent` with no arguments +- **THEN** the topic index SHALL include a row for `report` + +#### Scenario: Fetching report captures its topic + +- **WHEN** an agent runs `taskless agent report` +- **THEN** PostHog SHALL receive a `cli_agent` event whose `topic` property is `report` diff --git a/openspec/changes/streamlined-onboarding-report/specs/cli-detect/spec.md b/openspec/changes/streamlined-onboarding-report/specs/cli-detect/spec.md new file mode 100644 index 00000000..2e30236f --- /dev/null +++ b/openspec/changes/streamlined-onboarding-report/specs/cli-detect/spec.md @@ -0,0 +1,195 @@ +## 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, the tools that +run commands at commit time, and the command-line tools present on the host's +`PATH`. 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. + +ast-grep and Vale are linters for this purpose. Anything under `.taskless/` is +Taskless's own configuration, not the repository's, and SHALL NOT be evidence +for any linter. + +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. + +Host tools (`gh`, `git`, `jq`, `glab`) SHALL be established by looking for a file +of that name on `PATH`, and SHALL NOT be established by executing anything. Each +SHALL be reported as present or absent, and as applicable or not where the +tool's usefulness depends on the repository: `gh` is applicable only when +`origin` is a GitHub remote, and `glab` only when `origin` is a GitLab remote +(`gitlab.com`, or a host whose name begins with `gitlab.`). An inapplicable tool +SHALL carry the reason. + +#### 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 + +#### Scenario: ast-grep is detected from its config + +- **WHEN** the working directory contains `sgconfig.yml` or `.sgconfig.yml` + outside `.taskless/` +- **THEN** `detect --json` SHALL report `ast-grep` as a linter with that path as + its evidence + +#### Scenario: Vale is detected from its config + +- **WHEN** the working directory contains `.vale.ini` or `_vale.ini` outside + `.taskless/` +- **THEN** `detect --json` SHALL report `vale` as a linter with that path as its + evidence + +#### Scenario: Taskless's own configuration is not the repository's + +- **WHEN** the only `sgconfig.yml` or `.vale.ini` in the working directory is + under `.taskless/` +- **THEN** `detect --json` SHALL NOT report `ast-grep` or `vale` + +#### Scenario: Host tools are reported by presence + +- **WHEN** `gh` is on `PATH` and `glab` is not, in a repository whose `origin` + is a GitHub remote +- **THEN** `detect --json` SHALL report `gh` as present and applicable +- **AND** SHALL report `glab` as absent +- **AND** SHALL NOT have executed either + +#### Scenario: A host tool for the other forge is inapplicable + +- **WHEN** `glab` is on `PATH` in a repository whose `origin` is a GitHub remote +- **THEN** `detect --json` SHALL report `glab` as present and not applicable, + with the reason that the repository has no GitLab origin + +### 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. + +The object SHALL carry `hostTools`, one entry per host tool with its `name`, +`present`, `applicable`, the `path` it was found at when present, and the +`reason` when not applicable. It SHALL carry `agent`, an object whose keys are +commands the agent may choose to run and whose values say in one sentence what +each tells it. `agent` SHALL hold an entry only for a tool that is present and +applicable, and an entry SHALL state any way the command's result is +misleading (for example an exit code that does not reflect sign-in state). +The hints and the onboard recipe's host-tool text SHALL be drawn from one +source, so the two cannot describe a tool differently. + +#### 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, host tools, agent hints) + +#### Scenario: A usable tool carries a hint + +- **WHEN** `gh` is present and applicable +- **THEN** `agent` SHALL contain a `gh auth status` key whose value says it + verifies whether the user is signed in and GitHub can be used as a source + +#### Scenario: An unusable tool carries no hint + +- **WHEN** `glab` is absent, or present but not applicable +- **THEN** `agent` SHALL NOT contain a `glab` command + +#### Scenario: A misleading exit code is called out + +- **WHEN** `glab` is present and applicable +- **THEN** its `agent` entry SHALL state that the output, not the exit code, + shows whether the user is signed in diff --git a/openspec/changes/streamlined-onboarding-report/specs/cli-feedback-survey/spec.md b/openspec/changes/streamlined-onboarding-report/specs/cli-feedback-survey/spec.md new file mode 100644 index 00000000..3eed5847 --- /dev/null +++ b/openspec/changes/streamlined-onboarding-report/specs/cli-feedback-survey/spec.md @@ -0,0 +1,79 @@ +## REMOVED Requirements + +### Requirement: A survey invite is appended to surveyed recipes when the gate is open + +**Reason**: `onboard` leaves the surveyed recipes. Its user agrees to onboarding feedback with the opening yes, so the recipe sends that feedback itself, and the requirement's scenario that both onboarding paths carry the invite no longer holds. A MODIFIED block cannot drop that scenario. + +**Migration**: Replaced by "A survey invite is appended to surveyed rule-authoring recipes when the gate is open", which carries every other scenario unchanged and adds that onboarding carries no invite. Onboarding feedback is covered by "Onboarding feedback is sent without an invite". + +## ADDED Requirements + +### Requirement: A survey invite is appended to surveyed rule-authoring recipes when the gate is open + +When the CLI serves one of the surveyed recipes (`create-sg-rule`, `create-vale-rule`, `create-remote-rule`) through `taskless agent `, it SHALL append the survey invite to the served text if and only if every gate condition holds: + +- telemetry is enabled (neither `DO_NOT_TRACK=1` nor `TASKLESS_TELEMETRY_DISABLED=1`); +- `CI` is neither `"true"` nor `"1"`; +- the survey's `next_ask` timestamp is absent, unparseable, or not later than now. + +When the gate is open the CLI SHALL capture `survey shown` with `$survey_id`, write `next_ask` to now plus 10 days, and append the invite after the recipe's last section. When any condition fails the served text SHALL be byte-identical to the recipe as rendered without a survey. The `@taskless/cli/prompts` export SHALL never include the invite. + +`onboard` is not a surveyed recipe. Its user agreed to onboarding feedback with their opening yes, so the onboarding recipe sends that feedback itself, and an invite appended to it would ask a question that has already been answered. + +#### Scenario: Gate open on a surveyed topic + +- **WHEN** telemetry is enabled, `CI` is unset, `next_ask` is absent, and an agent runs `taskless agent create-sg-rule` +- **THEN** stdout SHALL be the `create-sg-rule` recipe followed by the survey invite +- **AND** PostHog SHALL receive `survey shown` with `$survey_id` +- **AND** `next_ask` SHALL be written to a time about 10 days in the future + +#### Scenario: Onboarding carries no invite + +- **WHEN** the gate would otherwise be open and either `taskless onboard` or `taskless agent onboard` serves the onboarding recipe +- **THEN** stdout SHALL be the recipe with no invite +- **AND** no survey event SHALL be captured and `next_ask` SHALL be unchanged + +#### Scenario: Within the window + +- **WHEN** `next_ask` is later than now and an agent runs `taskless agent create-vale-rule` +- **THEN** stdout SHALL be the recipe with no invite +- **AND** no survey event SHALL be captured and `next_ask` SHALL be unchanged + +#### Scenario: Telemetry disabled + +- **WHEN** `DO_NOT_TRACK=1` and an agent runs any surveyed topic +- **THEN** stdout SHALL be the recipe with no invite +- **AND** `next_ask` SHALL NOT be read or written + +#### Scenario: Running in CI + +- **WHEN** `CI=true` and telemetry is enabled and an agent runs any surveyed topic +- **THEN** stdout SHALL be the recipe with no invite +- **AND** no survey event SHALL be captured + +#### Scenario: Unsurveyed topic + +- **WHEN** the gate would otherwise be open and an agent runs `taskless agent check` +- **THEN** stdout SHALL be the recipe with no invite +- **AND** `next_ask` SHALL be unchanged + +#### Scenario: Corrupt cadence file is repaired + +- **WHEN** `next_ask` contains text that is not a number and an agent runs a surveyed topic with the other conditions met +- **THEN** the invite SHALL be appended +- **AND** `next_ask` SHALL be overwritten with a valid timestamp + +### Requirement: Onboarding feedback is sent without an invite + +The `rule-feedback` recipe SHALL support being reached from the onboarding recipe rather than from an invite. Reached that way, the agent SHALL omit `verbatim`, answer `ruleKind` with `none (onboarding)`, fill every other answer from its own observation of the session, and send without asking the user anything. `feedback send` SHALL continue to honor the telemetry opt-out, so a user with `DO_NOT_TRACK=1` or `TASKLESS_TELEMETRY_DISABLED=1` sends nothing. + +#### Scenario: Onboarding sends feedback unprompted + +- **WHEN** the agent reaches `rule-feedback` from the onboarding recipe +- **THEN** the recipe SHALL direct it to send the payload without putting a question to the user +- **AND** `ruleKind` SHALL be `none (onboarding)` + +#### Scenario: The opt-out still holds + +- **WHEN** `DO_NOT_TRACK=1` and the agent runs `feedback send` at the end of onboarding +- **THEN** nothing SHALL be sent diff --git a/openspec/changes/streamlined-onboarding-report/specs/cli-onboard/spec.md b/openspec/changes/streamlined-onboarding-report/specs/cli-onboard/spec.md new file mode 100644 index 00000000..eed6558e --- /dev/null +++ b/openspec/changes/streamlined-onboarding-report/specs/cli-onboard/spec.md @@ -0,0 +1,120 @@ +## REMOVED Requirements + +### Requirement: Onboard recipe follows the canonical recipe template and is conversational + +**Reason**: Onboarding no longer runs as a conversation. Every question it asked (which sources, whether to materialize each bullet, when the rules run, whether onboarding is complete) was a point where a fresh user stopped. The flow now asks one question and finishes on its own. + +**Migration**: Replaced by "Onboard recipe follows the canonical recipe template and asks one question". The source menu becomes "mine every reachable source"; per-bullet materialization becomes "author every locally expressible candidate"; the CI/hooks decision moves to the very end, as a strongly encouraged CI offer with `hooks` as the fallback; the mark-complete confirmation is covered by the opening yes. + +## ADDED Requirements + +### Requirement: Onboard recipe follows the canonical recipe template and asks one question + +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 topic version SHALL be 6. + +The recipe SHALL instruct the agent to: + +1. Read `.taskless/taskless.json` and respect the `install.onboarded` field. +2. Ask the user exactly one question, "Do you want me to set up Taskless for you?", set apart from surrounding text by blank lines, and through the host's structured question tool when the host has one. The recipe SHALL state that a yes covers mining the repository, writing rules, publishing the report, and sending onboarding feedback. On anything but a yes the agent SHALL stop. +3. 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, rule styles, host tools, and `agent` hints. +4. Mine every reachable source without asking which: codebase TODOs/FIXMEs, agent-memory files (CLAUDE.md, AGENTS.md, .cursorrules, and similar), review comments through whichever host tool `detect` reports present and applicable (pull requests through `gh`, merge requests through `glab`), and any MCP the agent has that reaches a bug tracker, an engineering-context tool, or architectural decisions. Whether to run a hinted command such as `gh auth status`, and which MCPs to use, SHALL be left to the agent. +5. 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; drop one-off nits and pure formatting feedback. +6. Author every candidate the routing surface sends to a local destination (`legacy`, `sg`, or `vale`) by following the `route` topic, without asking per candidate. +7. Record, and not author, every candidate the agent judges better solved by a runtime rule, for the report's `cloud` tier. +8. Once the rules are written, run `taskless onboard --mark-complete` without asking. +9. Hand off to the `report` topic to publish the report, and, in parallel, send onboarding feedback by following the `rule-feedback` topic as the respondent. +10. Close by showing the report summary, then, as the last thing the agent does, offer to wire `taskless check` into the repository's CI by following the `ci` topic, and strongly encourage it: Taskless checks in CI are what keep agents from reintroducing the patterns these rules catch. The offer SHALL name the CI systems `detect --json` found. On a yes the agent SHALL follow the `ci` topic; on a no it SHALL name the `hooks` topic in one line as the local alternative and stop. + +The recipe SHALL operate logged out throughout. It SHALL NOT direct the agent to `auth login`, and SHALL NOT author a runtime rule. + +Beyond the opening question and the closing CI offer, the recipe SHALL permit a question only when the agent is unsure what rules to write AND the `report` topic's gate has refused for too few Taskless rules. It SHALL limit such a question to what would let the agent write the missing rules. + +The recipe SHALL describe a detected tool as present, never as verified, and SHALL NOT instruct the agent to run `command -v` or any equivalent probe for a tool `detect` reports on. + +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. + +When a review-comment source is unavailable, the recipe SHALL say in one line why, stating the reason that cannot be remedied first: a repository whose `origin` is on neither GitHub nor GitLab SHALL be told there are no pull or merge requests to mine, not that a CLI is missing. + +The recipe SHALL NOT restate the destination criterion itself; it SHALL reference the `route` topic. + +#### 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 v6)` + +#### Scenario: The opening question is the only scripted question + +- **WHEN** the recipe `## Steps` section is read +- **THEN** it SHALL contain the question "Do you want me to set up Taskless for you?" +- **AND** SHALL direct the agent to use the host's structured question tool when one exists +- **AND** no other step SHALL direct the agent to ask the user a question, except the step handling a refused report gate and the closing CI offer + +#### 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 authors rules + +#### 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: Sources are mined without a menu + +- **WHEN** the recipe `## Steps` section is read +- **THEN** it SHALL name codebase TODOs/FIXMEs, agent-memory files, review comments, and MCP-reachable trackers and engineering context as sources +- **AND** SHALL NOT direct the agent to ask the user which sources to include + +#### 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 review-comment source SHALL be included +- **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 review-comment source SHALL NOT be included +- **AND** the recipe SHALL state in one line that `gh` was not found on the PATH + +#### Scenario: A repository on neither host is told the source does not apply + +- **WHEN** the recipe is rendered for a repository whose `origin` is on neither GitHub nor GitLab, whatever tools are on `PATH` +- **THEN** the review-comment source SHALL NOT be included +- **AND** the stated reason SHALL be that there are no pull or merge requests to mine + +#### Scenario: Onboarding stays logged out + +- **WHEN** `onboard.md` is read +- **THEN** it SHALL NOT direct the agent to `auth login` +- **AND** SHALL direct runtime-shaped candidates to the report's `cloud` tier rather than to authoring + +#### Scenario: Onboarding completes without a confirmation question + +- **WHEN** the recipe `## Steps` section is read +- **THEN** it SHALL direct the agent to run `taskless onboard --mark-complete` once the rules are written +- **AND** SHALL NOT direct the agent to ask before doing so + +#### Scenario: Onboarding hands off to the report and sends feedback + +- **WHEN** the recipe `## Steps` section is read +- **THEN** a step after mark-complete SHALL direct the agent to the `report` topic +- **AND** SHALL direct it to follow the `rule-feedback` topic as the respondent + +#### Scenario: CI is offered last and strongly encouraged + +- **WHEN** the recipe's closing step is read +- **THEN** it SHALL appear after the report summary is shown and be the last step +- **AND** it SHALL direct the agent to offer wiring `check` into CI via the `ci` topic, naming the CI systems `detect --json` found +- **AND** it SHALL direct the agent to strongly encourage a yes, stating that checks in CI keep agents from reintroducing what the rules catch + +#### Scenario: Declining CI points at hooks + +- **WHEN** the user declines the CI offer +- **THEN** the recipe SHALL direct the agent to name the `hooks` topic in one line and stop + +#### Scenario: Recipe references its neighbors in See Also + +- **WHEN** the `## See Also` section is read +- **THEN** it SHALL include references to `taskless agent route`, `taskless agent report`, `taskless agent rule-feedback`, `taskless agent ci`, and `taskless agent hooks` diff --git a/openspec/changes/streamlined-onboarding-report/specs/cli-report/spec.md b/openspec/changes/streamlined-onboarding-report/specs/cli-report/spec.md new file mode 100644 index 00000000..2f976647 --- /dev/null +++ b/openspec/changes/streamlined-onboarding-report/specs/cli-report/spec.md @@ -0,0 +1,229 @@ +## Purpose + +Defines `taskless report`: the command that turns a repository's Taskless rules, plus what the agent learned in its session, into a redacted ReportV1 published to tskl.es as an unlisted page, and the `report` agent topic that drives it. + +## ADDED Requirements + +### Requirement: Report subcommand exists with a draft mode, a publish mode, and delete + +The CLI SHALL provide a `taskless report` subcommand accepting the standard `--dir`/`-d` working-directory flag. Invoked with no mode flag it SHALL print a draft. Invoked with `--from ` it SHALL publish. `taskless report delete ` SHALL delete a previously published report. The subcommand SHALL require no authentication in any mode, and SHALL bootstrap `.taskless/` before reading or writing under it. + +#### Scenario: Report is registered + +- **WHEN** a user runs `taskless --help` +- **THEN** `report` SHALL appear in the list of subcommands + +#### Scenario: Report works logged out + +- **WHEN** a user who has never logged in runs `taskless report` or `taskless report --from ` +- **THEN** the command SHALL NOT prompt for or require authentication + +### Requirement: The draft states what the CLI knows and what the agent must supply + +`taskless report` with no mode flag SHALL print a single JSON object to stdout and exit 0. The object SHALL contain every report field the CLI can establish from the working directory: the producer (`kind: "cli"` and this build's version), the scope derived from the repository's `origin` remote, one entry per rule directory under `.taskless/rules/` with its id, engine, tier, language, and test status and fixtures, and the default projection inputs. It SHALL also contain `missing`, a list naming each fact the agent must supply before the report can publish. A fact the CLI can establish SHALL NOT appear in `missing`. + +The draft SHALL NOT perform any network call. + +#### Scenario: A repository with local rules + +- **WHEN** `.taskless/rules/` holds three `sg` rules and one `vale` rule and the user runs `taskless report` +- **THEN** stdout SHALL be a JSON object listing four rules, each with `tier: "cli"`, its engine, and its test status +- **AND** `missing` SHALL name the session facts still required, including `history` and the projection estimates + +#### Scenario: A GitHub origin becomes a GitHub scope + +- **WHEN** the repository's `origin` is a GitHub remote +- **THEN** the draft's scope SHALL carry `provider: "github"` with the owner, repository, and canonical URL + +#### Scenario: Any other origin becomes an unnamed scope + +- **WHEN** the repository has no `origin`, or a non-GitHub `origin` +- **THEN** the draft's scope SHALL carry `provider: "none"` with a label + +#### Scenario: The draft is offline + +- **WHEN** `taskless report` runs with no network available +- **THEN** it SHALL print the draft and exit 0 + +### Requirement: The agent supplies session facts, not a report + +`taskless report --from ` SHALL read a facts file, not a ReportV1. The facts file SHALL carry only what the agent knows from its session: the history counts and the window they were mined over, per-rule titles and origin counts and fix counts with their basis, rules authored into an existing linter (`legacy` tier), runtime-shaped candidates the agent did not author (`cloud` tier), the agent's estimates of context tokens per session and calls per catch, the agent's own redaction counts, and the fixtures it elects to withhold. The CLI SHALL merge the facts with what it establishes itself, and SHALL treat its own values as authoritative for every field it can establish. + +The facts file SHALL be validated against a published facts schema before anything else happens. The `report` recipe SHALL embed that schema. + +#### Scenario: Facts override nothing the CLI knows + +- **WHEN** the facts file names a rule id that exists under `.taskless/rules/` and gives it a different engine +- **THEN** the published report SHALL carry the engine the CLI established + +#### Scenario: Malformed facts are refused before any other work + +- **WHEN** the facts file is not valid JSON or fails the facts schema +- **THEN** the command SHALL exit non-zero with `REPORT_INVALID` naming each failing field +- **AND** SHALL NOT perform any network call + +#### Scenario: Cloud candidates are carried as cloud tier + +- **WHEN** the facts file lists a runtime-shaped candidate +- **THEN** the published report SHALL include it with `tier: "cloud"` and `engine: "runtime"` +- **AND** its tests status SHALL be `not-run` + +### Requirement: The CLI computes the projection + +The CLI SHALL compute `projection.perMonth` itself from the merged facts and SHALL NOT accept per-month figures from the facts file. Per-tier counts SHALL be normalized to a 30-day month over the mining window the facts declare. The projection's `inputs` SHALL carry the agent's estimates for `contextTokens` and `callsPerCatch` and the CLI's defaults for every other input. + +#### Scenario: Per-month figures in the facts are not accepted + +- **WHEN** the facts file includes a `perMonth` value +- **THEN** the command SHALL refuse it as an unknown field under `REPORT_INVALID` + +#### Scenario: Window normalization + +- **WHEN** the facts declare a 90-day window and rules in the `cli` tier with a combined fix count of 30 +- **THEN** the published `projection.perMonth.agentCatches.cli` SHALL be 10 + +### Requirement: Publishing is gated on a meaningful report + +`taskless report --from` SHALL refuse to publish unless the merged report holds at least three Taskless rules. A Taskless rule is one in the `cli` tier, or one in the `legacy` tier that the agent authored into the repository's own linter, whose test status is neither `fail` nor `refused`. Rules in the `cloud` tier SHALL NOT count toward the three; they are carried in addition. The projection SHALL NOT gate publishing. A refusal SHALL exit non-zero with `REPORT_INSUFFICIENT`, SHALL state how many of the required three exist, and SHALL perform no network call. + +#### Scenario: Too few local rules + +- **WHEN** the merged report holds two passing `sg` rules and four `cloud` candidates +- **THEN** the command SHALL exit non-zero with `REPORT_INSUFFICIENT` +- **AND** the message SHALL state that two of the required three local rules exist + +#### Scenario: Legacy rules the agent wrote count + +- **WHEN** the merged report holds one passing `sg` rule and two `legacy` rules the agent authored into the repository's eslint config +- **THEN** the gate SHALL count three rules and SHALL NOT refuse + +#### Scenario: A small projection does not refuse + +- **WHEN** the merged report holds three passing local rules and a projection near zero +- **THEN** the gate SHALL NOT refuse + +#### Scenario: A failing rule does not count + +- **WHEN** one of three local rules has test status `fail` +- **THEN** the gate SHALL count two local rules + +### Requirement: The CLI redacts and withholds before publishing + +tskl.es performs no redaction of its own. Before publishing, the CLI SHALL scan every fixture and every string supplied by the facts file for secrets, email addresses, host names, IP addresses, and phone numbers. A fixture containing a match SHALL be withheld in full, never partially masked: its code SHALL be omitted, its side SHALL be recorded in that rule's `withheld`, and `redaction.withheldFixtures` SHALL count it. A match in any other agent-supplied string SHALL refuse the publish with `REPORT_REDACTION`, naming the field, so the agent can rewrite it. A fixture whose code exceeds the schema's length limit SHALL be withheld the same way. + +`redaction.counts` SHALL be the sum of the agent's declared counts and the CLI's own matches per category. `redaction.policyVersion` SHALL identify the CLI's redaction policy. + +#### Scenario: A secret in a fixture withholds the fixture + +- **WHEN** a rule's failing fixture contains an AWS access key +- **THEN** the published rule SHALL have `tests.fail: null` and `"fail"` in `tests.withheld` +- **AND** `redaction.counts.secret` SHALL include that match +- **AND** no part of the fixture SHALL appear in the published payload + +#### Scenario: An email in a rule title refuses the publish + +- **WHEN** the facts give a rule a title containing an email address +- **THEN** the command SHALL exit non-zero with `REPORT_REDACTION` naming that rule's title +- **AND** SHALL perform no network call + +#### Scenario: The agent's counts are carried + +- **WHEN** the facts declare `person: 2` and the CLI matches no person names +- **THEN** `redaction.counts.person` SHALL be 2 + +### Requirement: The payload is validated against the vendored ReportV1 schema before it is sent + +The CLI SHALL carry a vendored copy of the ReportV1 schema published at `https://tskl.es/api/v1/schema`, refreshed by a generate script and committed. The assembled payload SHALL be validated against it, and SHALL be measured against the publish size limit, before any network call. A payload that fails either check SHALL NOT be sent. + +#### Scenario: An invalid payload is never sent + +- **WHEN** the assembled payload fails the vendored schema +- **THEN** the command SHALL exit non-zero with `REPORT_INVALID` naming each failing path +- **AND** SHALL perform no network call + +#### Scenario: An oversized payload is never sent + +- **WHEN** the assembled payload exceeds 262,144 bytes +- **THEN** the command SHALL exit non-zero with `REPORT_TOO_LARGE` +- **AND** SHALL perform no network call + +### Requirement: Publishing follows the tskl.es retry contract + +The CLI SHALL publish with `POST https://tskl.es/api/v1/reports` and SHALL always publish with `private` retention. It SHALL retry only on a `publish_failed` (502) response, a bounded number of times. It SHALL NOT retry after a timeout or a dropped connection, since the report may have been published; it SHALL instead exit non-zero with `REPORT_PUBLISH_UNKNOWN`, stating that a report may exist and that re-running may create a duplicate. A `rate_limited` (429) response SHALL exit non-zero with `REPORT_RATE_LIMITED` and SHALL NOT be retried automatically. A `validation_error` (400) SHALL exit non-zero with `REPORT_INVALID` carrying the service's details. + +#### Scenario: A 502 is retried + +- **WHEN** the first publish attempt returns 502 and the second returns 201 +- **THEN** the command SHALL succeed + +#### Scenario: A timeout is not retried + +- **WHEN** a publish attempt times out +- **THEN** the command SHALL NOT send a second request +- **AND** SHALL exit non-zero with `REPORT_PUBLISH_UNKNOWN` + +#### Scenario: A rate limit is surfaced + +- **WHEN** the publish returns 429 +- **THEN** the command SHALL exit non-zero with `REPORT_RATE_LIMITED` and SHALL NOT retry + +### Requirement: A published report is recorded and summarized + +On a 201 response the CLI SHALL write `.taskless/reports/.json` (UTC) holding the report URL, the delete credential exactly as the service returned it, and the expiry. It SHALL then print a brief summary rendered from a CLI template: one line per rule giving its title and tier, the headline projection, the report URL, the expiry, and one sentence stating the report can be deleted after review. With `--json` it SHALL instead print a JSON object carrying the URL, the expiry, the record's path, and the rendered summary. The delete credential SHALL NOT appear on stdout in either form. + +#### Scenario: The record is written + +- **WHEN** a publish succeeds +- **THEN** exactly one new file SHALL exist under `.taskless/reports/` holding the URL, the delete credential, and the expiry + +#### Scenario: The summary names the link and the way back + +- **WHEN** a publish succeeds without `--json` +- **THEN** stdout SHALL list each published rule's title and tier +- **AND** SHALL print the report URL +- **AND** SHALL state that the report can be deleted after review + +#### Scenario: The delete credential stays on disk + +- **WHEN** a publish succeeds, with or without `--json` +- **THEN** stdout SHALL NOT contain the delete credential + +### Requirement: A published report can be deleted from its record + +`taskless report delete ` SHALL read the named record under `.taskless/reports/`, delete the report by sending an HTTP `DELETE` to the recorded `deleteUrl`, the URL tskl.es returned for that purpose, and on success remove the record and print a one-line confirmation. A record that does not exist SHALL exit non-zero with `REPORT_NOT_FOUND`. A failed delete SHALL leave the record in place. + +#### Scenario: Deleting a report + +- **WHEN** a user runs `taskless report delete 2026-10-13_142200.json` and the service confirms the deletion +- **THEN** the record SHALL be removed +- **AND** the command SHALL print a confirmation and exit 0 + +#### Scenario: A failed delete keeps the credential + +- **WHEN** the service refuses or cannot be reached +- **THEN** the record SHALL remain on disk +- **AND** the command SHALL exit non-zero + +### Requirement: The report recipe drives the flow + +The CLI SHALL embed a `report` recipe following the canonical recipe template. It SHALL instruct the agent to: run `taskless report` and read `missing`; supply each missing fact from its session, writing the facts to `.taskless/.tmp-report.json`; estimate context tokens and calls per catch from how this user's sessions actually run; run `taskless report --from` that path; on `REPORT_INSUFFICIENT`, ask the user only what would let it author the rules or establish the facts the refusal names, then retry; on success, show the CLI's summary as printed and tell the user the report can be deleted after review if they ask; delete the facts file; and on a request to delete, run `taskless report delete` with the record. The recipe SHALL embed the facts schema and SHALL state that tskl.es does not redact, so anything the agent cannot vouch for is to be withheld. + +#### Scenario: The recipe asks questions only on refusal + +- **WHEN** the `report` recipe's steps are read +- **THEN** the only step that permits a question to the user SHALL be the one handling `REPORT_INSUFFICIENT` + +#### Scenario: The recipe embeds the facts schema + +- **WHEN** an agent runs `taskless agent report` +- **THEN** stdout SHALL open with `# Topic: report` and contain the JSON Schema for the facts file + +### Requirement: Report emits intent telemetry without report content + +The `report` subcommand SHALL emit `cli_report_drafted` for a draft, `cli_report_published` on a successful publish carrying only rule counts by tier and the gate's outcome, `cli_report_refused` carrying the refusal code, and `cli_report_deleted` on a successful delete. No event SHALL carry the report URL, the delete credential, any rule title, or any fixture. + +#### Scenario: A publish event carries counts only + +- **WHEN** a report publishes +- **THEN** PostHog SHALL receive `cli_report_published` with per-tier rule counts +- **AND** the event SHALL NOT contain the report URL or the delete credential diff --git a/openspec/changes/streamlined-onboarding-report/specs/cli-taskless-bootstrap/spec.md b/openspec/changes/streamlined-onboarding-report/specs/cli-taskless-bootstrap/spec.md new file mode 100644 index 00000000..3b16de4c --- /dev/null +++ b/openspec/changes/streamlined-onboarding-report/specs/cli-taskless-bootstrap/spec.md @@ -0,0 +1,63 @@ +## MODIFIED Requirements + +### Requirement: Onboarded field semantics are 3-state and consent-gated + +The optional `install.onboarded` boolean field on the install manifest SHALL be interpreted by all readers as three meaningful states: + +| Value | Meaning | +| ------- | --------------------------------------------------------------------- | +| absent | Never explicitly onboarded (the post-install default) | +| `false` | Explicitly reset (treated equivalently to absent for gating purposes) | +| `true` | The user agreed to onboarding and the onboarding flow wrote its rules | + +Consent is the user's yes to the onboarding recipe's opening question. The agent records it by running `taskless onboard --mark-complete` once the rules are written, without a second confirmation. + +The field SHALL only be written by the `taskless onboard --mark-complete` subcommand. No other CLI code path (including `taskless init`, the wizard, `taskless report`, and any future migration) SHALL write or set this field automatically. The field MAY be edited manually by an advanced user; such edits are out of scope for the CLI's guarantees. + +#### Scenario: Absent and false both gate as "not onboarded" + +- **WHEN** any consumer of the manifest reads `install.onboarded` +- **AND** the field is absent OR `false` +- **THEN** the consumer SHALL treat the user as not yet onboarded for any gating purpose + +#### Scenario: True gates as "onboarded" + +- **WHEN** any consumer of the manifest reads `install.onboarded` +- **AND** the field is `true` +- **THEN** the consumer SHALL treat the user as onboarded + +#### Scenario: No CLI path writes onboarded except mark-complete + +- **WHEN** the codebase is searched for writes to `install.onboarded` +- **THEN** the only producer SHALL be the `taskless onboard --mark-complete` subcommand + +#### Scenario: Publishing a report does not mark onboarding complete + +- **WHEN** `taskless report --from` publishes a report in a repository whose `install.onboarded` is absent +- **THEN** `install.onboarded` SHALL still be absent afterwards + +## ADDED Requirements + +### Requirement: Migration 11 creates the reports directory and ignores it + +Migration `11` SHALL create `.taskless/reports/` holding a short stub that says what the directory records, and SHALL add `/reports/` to `.taskless/.gitignore` through the shared gitignore helper. Each record in the directory holds a credential that deletes a page anyone with its link can view, so the directory SHALL never be committed. The migration SHALL NOT modify any shipped migration; a fresh scaffold reaches the same state by running migrations `1` through `11` in order. + +#### Scenario: An existing scaffold gains the directory and the ignore line + +- **WHEN** `taskless.json` records version 10 and the CLI bootstraps `.taskless/` +- **THEN** migration 11 SHALL run +- **AND** `.taskless/reports/` SHALL exist with its stub +- **AND** `.taskless/.gitignore` SHALL contain a `/reports/` line +- **AND** `taskless.json` SHALL record version 11 + +#### Scenario: A fresh scaffold ends in the same state + +- **WHEN** `.taskless/` does not exist and the CLI bootstraps it +- **THEN** `.taskless/reports/` SHALL exist +- **AND** `.taskless/.gitignore` SHALL contain `/reports/` + +#### Scenario: Migration 11 is idempotent + +- **WHEN** `.taskless/reports/` already exists with records in it and `.taskless/.gitignore` already contains `/reports/`, and migration 11 runs +- **THEN** every existing record SHALL be unchanged +- **AND** the file SHALL contain exactly one `/reports/` line afterwards diff --git a/openspec/changes/streamlined-onboarding-report/specs/skill-taskless/spec.md b/openspec/changes/streamlined-onboarding-report/specs/skill-taskless/spec.md new file mode 100644 index 00000000..ecb44dee --- /dev/null +++ b/openspec/changes/streamlined-onboarding-report/specs/skill-taskless/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: Skill routes report requests to the report topic + +The skill body's topic table SHALL map a request to make, publish, regenerate, or delete a Taskless report to the `report` topic, invoked through the `%(TASKLESS_CLI)s` placeholder like every other row. The skill SHALL add no report steps of its own; the fetched recipe carries them. + +#### Scenario: A report request routes to the report topic + +- **WHEN** the skill body is read by an agent +- **THEN** its topic table SHALL include a row mapping report requests to `%(TASKLESS_CLI)s agent report` + +#### Scenario: The skill carries no report steps + +- **WHEN** the skill body is read +- **THEN** it SHALL NOT describe how to assemble, publish, or delete a report diff --git a/openspec/changes/streamlined-onboarding-report/tasks.md b/openspec/changes/streamlined-onboarding-report/tasks.md new file mode 100644 index 00000000..6c4d19e3 --- /dev/null +++ b/openspec/changes/streamlined-onboarding-report/tasks.md @@ -0,0 +1,44 @@ +## 1. Detect: ast-grep, Vale, glab, and agent hints (PR 1, bottom of the stack) + +- [ ] 1.1 Add the `.changeset/streamlined-onboarding-report.md` (`patch`) on this branch before cutting any child branch; it is grown by each later group. Verify `git diff --stat origin/main` shows it. +- [ ] 1.2 Add `ast-grep` (`sgconfig.yml`, `.sgconfig.yml`) and `vale` (`.vale.ini`, `_vale.ini`) to the linter signals in `detect/scan.ts`, pruning `.taskless/` from their evidence. Verify with detect tests: a root `sgconfig.yml` reports `ast-grep`, a `.taskless/sgconfig.yml` alone reports nothing, and the same for Vale. +- [ ] 1.3 Replace the per-tool logic in `host-tools.ts` with one table (name, applicability predicate, inapplicable reason, hint command, hint text) and add `glab`, applicable when `origin` is `gitlab.com` or a `gitlab.*` host. Verify unit tests cover `gh`/`glab` × GitHub/GitLab/no origin, and that nothing is executed. +- [ ] 1.4 Add `hostTools` and `agent` to the detect output schema and `detect --json`; `agent` holds hints only for present and applicable tools, and the `glab` hint says to read the output, not the exit code. Verify a detect JSON test asserts both fields and the schema validation passes. +- [ ] 1.5 Render the onboard recipe's host-tool passages from the same table, covering the review-comment source for `gh` and `glab`. Verify the existing `onboard`/`agent onboard` byte-parity tests pass in every host-tool state, including a GitLab origin. +- [ ] 1.6 Run `pnpm typecheck` and `pnpm lint`; both pass. + +## 2. Rename rules/report → rules/check-summary (PR 2) + +- [ ] 2.1 `git mv packages/cli/src/rules/report.ts packages/cli/src/rules/check-summary.ts` and update importers (`plan-check.ts`, `recover.ts`, `verdicts.ts`, `test/verdicts.test.ts`, `test/check-snapshot.test.ts`). Verify `grep -rn "rules/report" packages/cli` is empty and `pnpm --filter @taskless/cli test` passes. +- [ ] 2.2 Run `pnpm typecheck` and `pnpm lint`; both pass. + +## 3. taskless report (PR 3) + +- [ ] 3.1 Add `scripts/fetch-report-schema.ts` and a `generate:report` script vendoring `src/generated/report-v1.schema.json` in the style of `fetch-api-schema.ts`. Validate with `z.fromJSONSchema()` over the vendored document (D6). Verify with a test that validates the schema's `examples[0]` and rejects each counter-example. +- [ ] 3.2 Add migration `0011` creating `.taskless/reports/` with its stub and adding `/reports/` to `.taskless/.gitignore`. Verify migration tests: upgrade from version 10, fresh scaffold, and idempotence with existing records. +- [ ] 3.3 Build the draft: producer, scope from the `origin` remote, one entry per rule directory with tier, engine, language, test status and first fail/pass fixture, default projection inputs, and `missing[]`. Verify `taskless report` against fixture repositories (sg+vale rules; GitHub origin; no origin) with a snapshot test, and that it makes no network call. +- [ ] 3.4 Define the facts zod schema (history, window, rule facts, `legacy[]`, `cloud[]`, estimates, agent redaction counts, withheld fixtures) and the merge in which CLI-established fields win. Verify tests for an engine conflict, an unknown `perMonth` key refused as `REPORT_INVALID`, and cloud candidates published as `cloud`/`runtime`/`not-run`. +- [ ] 3.5 Implement the projection (D3) and the gate. Verify tests: the 90-day/30-fixes → 10 normalization, two local rules → `REPORT_INSUFFICIENT` naming 2 of 3, one `sg` plus two agent-written `legacy` rules passing, a near-zero projection not refusing, and a failing rule not counted. +- [ ] 3.6 Implement redaction (D5): the five pattern classes, withholding a whole fixture on a hit or when over length, `REPORT_REDACTION` for an agent-supplied string, summed counts, and `policyVersion`. Verify tests per class and that no withheld fixture byte appears in the payload. +- [ ] 3.7 Validate against the vendored schema and the 262,144-byte cap before sending. Verify tests that an invalid or oversized payload exits non-zero and that the stubbed client is never called. +- [ ] 3.8 Implement the publish client (D7): private retention, a 502 retried at most twice, no retry on timeout (`REPORT_PUBLISH_UNKNOWN`), 429 → `REPORT_RATE_LIMITED`, 400 → `REPORT_INVALID` with the service's details, base URL overridable for tests. Verify each path against a local test server. +- [ ] 3.9 On 201, write `.taskless/reports/.json` with `{ url, deleteUrl, expiresAt }` and print the templated summary (rule title and tier per line, headline projection, URL, expiry, the deletable-after-review sentence); `--json` prints URL, expiry, record path, and summary. Verify tests that exactly one record is written and that the delete credential appears on stdout in neither form. +- [ ] 3.10 Implement `taskless report delete ` as an HTTP `DELETE` to the recorded `deleteUrl`: remove the record on success, keep it on failure, `REPORT_NOT_FOUND` for a missing record. Verify against the local test server. +- [ ] 3.11 Emit `cli_report_drafted`, `cli_report_published` (per-tier counts and gate outcome), `cli_report_refused` (code), and `cli_report_deleted`. Verify a telemetry test that no event carries a URL, credential, title, or fixture. +- [ ] 3.12 Write `agent/report.md` (check, supply what `missing` names, estimate from session habits, generate, handle `REPORT_INSUFFICIENT` with questions only about the rules it lacks, show the summary, mention deletion, delete the facts file, delete on request), embedding the facts JSON Schema. Register the topic in the agent index and classify it in the prompts topic list. Verify `taskless agent report` opens with `# Topic: report`, contains the schema, and the topic-membership and "recipes name only commands that exist" checks pass. +- [ ] 3.13 Extend the changeset with the `report` command. Run `pnpm typecheck` and `pnpm lint`; both pass. + +## 4. Onboard v6, skill, and feedback (PR 4) + +- [ ] 4.1 Rewrite `agent/onboard.md` as topic v6 per the `cli-onboard` spec: the one question, logged out, mine everything, author local candidates without asking, record runtime candidates for the `cloud` tier, `--mark-complete` without asking, hand off to `report` and `rule-feedback` in parallel, close by showing the summary and then, last, offering CI integration via the `ci` topic with a strong case for it (checks keep agents from reintroducing what the rules catch), naming `hooks` in one line on a no. Verify recipe tests for each scenario in the spec delta, and the two-path byte-parity tests. +- [ ] 4.2 Remove `onboard` from the surveyed topics in `survey/constants.ts`. Verify a survey test that onboarding serves no invite and leaves `next_ask` unchanged with the gate otherwise open. +- [ ] 4.3 Add the "reached from onboarding" branch to `agent/rule-feedback.md`: no `verbatim`, `ruleKind: none (onboarding)`, no question, send. Verify the recipe test and that `feedback send` still refuses under `DO_NOT_TRACK=1`. +- [ ] 4.4 Add the `report` row to the skill body and the `tskl` command body via `%(TASKLESS_CLI)s`. Verify the skill tests, including the 80-line cap. +- [ ] 4.5 Update `getOnboardTrailer` and the `onboard` command description if their wording still promises a conversational discovery flow. Verify the init trailer tests. +- [ ] 4.6 End to end: `pnpm build`, then in a scratch repository with a GitHub origin follow `pnpm cli onboard` as an agent would, through `report --from` against the local test server, and confirm the record, the summary, and `install.onboarded: true`. +- [ ] 4.7 Extend the changeset with the onboarding change. Run `pnpm typecheck` and `pnpm lint`; both pass. + +## 5. Archive (PR 5, tip of the stack) + +- [ ] 5.1 Run the pre-archive check from CLAUDE.md (commit, keep the SHA, `pnpm openspec archive streamlined-onboarding-report -y`, confirm every prior scenario of `cli-detect`, `cli-feedback-survey`, and `cli-taskless-bootstrap` survives, then reset to the SHA). +- [ ] 5.2 Archive for real, then update `openspec/specs/cli-onboard/spec.md`'s Purpose, which still describes a conversational flow. Verify `pnpm openspec validate --strict` passes on the result. From 54eb4819a24e9fc3474523ad681ef31237218772 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 13 Oct 2026 17:30:38 -0700 Subject: [PATCH 2/3] feat(detect): report ast-grep, Vale, host tools, and agent hints detect --json now reports ast-grep and Vale configurations as linters, skipping Taskless's own copies under .taskless/. It adds hostTools (gh, git, jq, and glab for GitLab origins), found on PATH and never run, and an agent section naming commands an agent may choose to run, such as gh auth status. One catalog drives detection, the inapplicable reasons, and the hints. --- .changeset/streamlined-onboarding-report.md | 2 +- .../streamlined-onboarding-report/tasks.md | 14 ++-- packages/cli/src/commands/detect.ts | 30 ++++++- packages/cli/src/detect/host-tool-catalog.ts | 68 +++++++++++++++ packages/cli/src/detect/host-tools.ts | 84 +++++++++++-------- packages/cli/src/detect/scan.ts | 13 +++ packages/cli/src/schemas/detect.ts | 29 +++++++ packages/cli/src/util/git-remote.ts | 32 +++++++ packages/cli/test/cli.test.ts | 1 + packages/cli/test/detect.test.ts | 66 +++++++++++++++ packages/cli/test/host-tools.test.ts | 79 ++++++++++++++++- 11 files changed, 372 insertions(+), 46 deletions(-) create mode 100644 packages/cli/src/detect/host-tool-catalog.ts diff --git a/.changeset/streamlined-onboarding-report.md b/.changeset/streamlined-onboarding-report.md index 6805300c..f9f5b32e 100644 --- a/.changeset/streamlined-onboarding-report.md +++ b/.changeset/streamlined-onboarding-report.md @@ -2,4 +2,4 @@ "@taskless/cli": patch --- -`taskless detect --json` now reports ast-grep and Vale configurations as linters (Taskless's own configuration under `.taskless/` excluded), the command-line tools on the host's `PATH` (`gh`, `git`, `jq`, and now `glab` for GitLab repositories) under `hostTools`, and an `agent` section of commands an agent may choose to run to check whether a source is usable, such as `gh auth status`. Detection still runs nothing and stays offline. +`taskless detect --json` now reports ast-grep and Vale configurations as linters (Taskless's own configuration under `.taskless/` excluded), the command-line tools on the host's `PATH` (`gh`, `git`, `jq`, and now `glab` for GitLab repositories) under `hostTools`, and an `agent` section of commands an agent may choose to run to check whether a source is usable, such as `gh auth status`. Detection still runs nothing and stays offline. `taskless info` lists `glab` among its tools too. diff --git a/openspec/changes/streamlined-onboarding-report/tasks.md b/openspec/changes/streamlined-onboarding-report/tasks.md index 6c4d19e3..f2714456 100644 --- a/openspec/changes/streamlined-onboarding-report/tasks.md +++ b/openspec/changes/streamlined-onboarding-report/tasks.md @@ -1,11 +1,11 @@ ## 1. Detect: ast-grep, Vale, glab, and agent hints (PR 1, bottom of the stack) -- [ ] 1.1 Add the `.changeset/streamlined-onboarding-report.md` (`patch`) on this branch before cutting any child branch; it is grown by each later group. Verify `git diff --stat origin/main` shows it. -- [ ] 1.2 Add `ast-grep` (`sgconfig.yml`, `.sgconfig.yml`) and `vale` (`.vale.ini`, `_vale.ini`) to the linter signals in `detect/scan.ts`, pruning `.taskless/` from their evidence. Verify with detect tests: a root `sgconfig.yml` reports `ast-grep`, a `.taskless/sgconfig.yml` alone reports nothing, and the same for Vale. -- [ ] 1.3 Replace the per-tool logic in `host-tools.ts` with one table (name, applicability predicate, inapplicable reason, hint command, hint text) and add `glab`, applicable when `origin` is `gitlab.com` or a `gitlab.*` host. Verify unit tests cover `gh`/`glab` × GitHub/GitLab/no origin, and that nothing is executed. -- [ ] 1.4 Add `hostTools` and `agent` to the detect output schema and `detect --json`; `agent` holds hints only for present and applicable tools, and the `glab` hint says to read the output, not the exit code. Verify a detect JSON test asserts both fields and the schema validation passes. -- [ ] 1.5 Render the onboard recipe's host-tool passages from the same table, covering the review-comment source for `gh` and `glab`. Verify the existing `onboard`/`agent onboard` byte-parity tests pass in every host-tool state, including a GitLab origin. -- [ ] 1.6 Run `pnpm typecheck` and `pnpm lint`; both pass. +- [x] 1.1 Add the `.changeset/streamlined-onboarding-report.md` (`patch`) on this branch before cutting any child branch; it is grown by each later group. Verify `git diff --stat origin/main` shows it. +- [x] 1.2 Add `ast-grep` (`sgconfig.yml`, `.sgconfig.yml`) and `vale` (`.vale.ini`, `_vale.ini`) to the linter signals in `detect/scan.ts`, pruning `.taskless/` from their evidence. Verify with detect tests: a root `sgconfig.yml` reports `ast-grep`, a `.taskless/sgconfig.yml` alone reports nothing, and the same for Vale. +- [x] 1.3 Replace the per-tool logic in `host-tools.ts` with one table (name, applicability predicate, inapplicable reason, hint command, hint text) and add `glab`, applicable when `origin` is `gitlab.com` or a `gitlab.*` host. Verify unit tests cover `gh`/`glab` × GitHub/GitLab/no origin, and that nothing is executed. +- [x] 1.4 Add `hostTools` and `agent` to the detect output schema and `detect --json`; `agent` holds hints only for present and applicable tools, and the `glab` hint says to read the output, not the exit code. Verify a detect JSON test asserts both fields and the schema validation passes. +- [x] 1.5 Feed the onboard recipe's host-tool list from the same catalog, so `glab` and every inapplicable reason come from one row. The review-comment source for `glab` waits for v6 (4.1): rendering merge requests into v5 would contradict the live `cli-onboard` scenario for repositories with no GitHub origin. Verify the existing `onboard`/`agent onboard` byte-parity tests pass. +- [x] 1.6 Run `pnpm typecheck` and `pnpm lint`; both pass. ## 2. Rename rules/report → rules/check-summary (PR 2) @@ -30,7 +30,7 @@ ## 4. Onboard v6, skill, and feedback (PR 4) -- [ ] 4.1 Rewrite `agent/onboard.md` as topic v6 per the `cli-onboard` spec: the one question, logged out, mine everything, author local candidates without asking, record runtime candidates for the `cloud` tier, `--mark-complete` without asking, hand off to `report` and `rule-feedback` in parallel, close by showing the summary and then, last, offering CI integration via the `ci` topic with a strong case for it (checks keep agents from reintroducing what the rules catch), naming `hooks` in one line on a no. Verify recipe tests for each scenario in the spec delta, and the two-path byte-parity tests. +- [ ] 4.1 Rewrite `agent/onboard.md` as topic v6 per the `cli-onboard` spec: the one question, logged out, mine everything, author local candidates without asking, record runtime candidates for the `cloud` tier, `--mark-complete` without asking, hand off to `report` and `rule-feedback` in parallel, close by showing the summary and then, last, offering CI integration via the `ci` topic with a strong case for it (checks keep agents from reintroducing what the rules catch), naming `hooks` in one line on a no. Verify recipe tests for each scenario in the spec delta, and the two-path byte-parity tests. Render the review-comment source from the host-tool catalog for both `gh` (pull requests) and `glab` (merge requests). - [ ] 4.2 Remove `onboard` from the surveyed topics in `survey/constants.ts`. Verify a survey test that onboarding serves no invite and leaves `next_ask` unchanged with the gate otherwise open. - [ ] 4.3 Add the "reached from onboarding" branch to `agent/rule-feedback.md`: no `verbatim`, `ruleKind: none (onboarding)`, no question, send. Verify the recipe test and that `feedback send` still refuses under `DO_NOT_TRACK=1`. - [ ] 4.4 Add the `report` row to the skill body and the `tskl` command body via `%(TASKLESS_CLI)s`. Verify the skill tests, including the 80-line cap. diff --git a/packages/cli/src/commands/detect.ts b/packages/cli/src/commands/detect.ts index acd7b1d8..058b0605 100644 --- a/packages/cli/src/commands/detect.ts +++ b/packages/cli/src/commands/detect.ts @@ -3,6 +3,7 @@ import process from "node:process"; import { defineCommand } from "citty"; +import { detectAgentHints, detectHostTools } from "../detect/host-tools"; import { detectRepository } from "../detect/scan"; import { outputSchema as detectOutputSchema } from "../schemas/detect"; import { makeErrorEnvelope } from "../types/errors"; @@ -11,7 +12,7 @@ export const detectCommand = defineCommand({ meta: { name: "detect", description: - "Scan the repo for configured linters, languages, rule styles, CI, and commit hooks (offline, deterministic)", + "Scan the repo for configured linters, languages, rule styles, CI, commit hooks, and host tools (offline, deterministic)", }, args: { dir: { @@ -31,9 +32,15 @@ export const detectCommand = defineCommand({ // event — the per-invocation cli_run denominator (emitted by the runner) // covers it, consistent with info under the cli_ telemetry taxonomy. + const [repository, hostTools] = await Promise.all([ + detectRepository(cwd), + detectHostTools(cwd), + ]); const result = { success: true as const, - ...(await detectRepository(cwd)), + ...repository, + hostTools, + agent: detectAgentHints(hostTools), }; if (args.json) { @@ -92,5 +99,24 @@ export const detectCommand = defineCommand({ console.log(` ${tool.name}: ${tool.evidence.join(", ")}`); } } + + // Presence, not verification: the file was found on PATH and not run. + console.log("\nHost tools (found on PATH, not run):"); + for (const tool of result.hostTools) { + const state = tool.applicable + ? tool.present + ? `on PATH (${tool.path ?? "unknown path"})` + : "not on PATH" + : `not applicable${tool.reason === undefined ? "" : ` (${tool.reason})`}`; + console.log(` ${tool.name}: ${state}`); + } + + const hints = Object.entries(result.agent); + if (hints.length > 0) { + console.log("\nFor an agent, if it chooses to check:"); + for (const [command, meaning] of hints) { + console.log(` ${command}: ${meaning}`); + } + } }, }); diff --git a/packages/cli/src/detect/host-tool-catalog.ts b/packages/cli/src/detect/host-tool-catalog.ts new file mode 100644 index 00000000..ce1201e6 --- /dev/null +++ b/packages/cli/src/detect/host-tool-catalog.ts @@ -0,0 +1,68 @@ +/** + * The host tools Taskless looks for, and everything said about each one. + * + * ONE TABLE, because three surfaces describe these tools — `detect --json`'s + * `hostTools`, its `agent` hints, and the onboard recipe's host-tool text — + * and two descriptions of one tool drift. Adding a tool here is the whole + * change: detection, applicability, the reason it does not apply, and the + * command an agent may run to check it all come from its row. + * + * PURE DATA, NO NODE IMPORTS. The recipe renderer is published as + * `@taskless/cli/prompts`, whose chunk graph may reach no node builtin, and it + * is meant to read this table too. + */ + +/** The forge an `origin` remote lives on, as far as host tools care. */ +export type OriginForge = "github" | "gitlab" | "other" | "none"; + +export interface HostToolEntry { + /** The command as it would be typed, e.g. `gh`. */ + name: string; + /** + * The forge this tool needs, or `undefined` for a tool that is useful in any + * repository. A tool tied to a forge is inapplicable everywhere else, + * however well it is installed. + */ + forge?: "github" | "gitlab"; + /** + * Why the tool is inapplicable, as a fragment that reads inside + * parentheses. Stated as a property of the PROJECT, because that is the + * part no install changes. + */ + inapplicableReason?: string; + /** + * A command the agent MAY run to learn whether the tool can be used as a + * source, and what it tells it. Detection never runs it; whether to is the + * agent's call. + */ + hint?: { command: string; meaning: string }; +} + +/** + * Fixed order, so a rendering that lists the tools is stable across hosts and + * two runs on one host cannot differ by ordering alone. + */ +export const HOST_TOOL_CATALOG: readonly HostToolEntry[] = [ + { + name: "gh", + forge: "github", + inapplicableReason: "this repository has no GitHub origin", + hint: { + command: "gh auth status", + meaning: + "Verifies whether the user is signed in to GitHub, so pull requests and their review comments can be used as a source.", + }, + }, + { name: "git" }, + { name: "jq" }, + { + name: "glab", + forge: "gitlab", + inapplicableReason: "this repository has no GitLab origin", + hint: { + command: "glab auth status", + meaning: + "Verifies whether the user is signed in to GitLab, so merge requests and their review comments can be used as a source. Read the output, not the exit code: it can exit 0 while signed out (gitlab-org/cli#911).", + }, + }, +]; diff --git a/packages/cli/src/detect/host-tools.ts b/packages/cli/src/detect/host-tools.ts index d8adacce..a00eea19 100644 --- a/packages/cli/src/detect/host-tools.ts +++ b/packages/cli/src/detect/host-tools.ts @@ -1,6 +1,11 @@ import type { HostTool } from "../prompts/recipes"; import { executableName, findOnPath } from "../rules/platform-binary"; -import { UNKNOWN_GH_OWNER, resolveRepositoryContext } from "../util/git-remote"; +import { + UNKNOWN_GH_OWNER, + isGitLabOrigin, + resolveRepositoryContext, +} from "../util/git-remote"; +import { HOST_TOOL_CATALOG } from "./host-tool-catalog"; // `HostTool` is declared in `prompts/recipes.ts` rather than here because the // render contract is what the shape exists to satisfy, and that module is @@ -8,24 +13,6 @@ import { UNKNOWN_GH_OWNER, resolveRepositoryContext } from "../util/git-remote"; // time, so this file's node builtins never enter the prompts chunk graph — the // constraint `assert-library-graphs` enforces in `vite.config.ts`. -/** - * The tools detected, in a fixed order. - * - * Fixed rather than derived from what was found, so a rendering that lists - * them is stable across hosts and two runs on the same host cannot differ by - * ordering alone. - */ -const DETECTED_TOOLS = ["gh", "git", "jq"] as const; - -/** - * Why `gh` is inapplicable when the repository has no GitHub `origin`. - * - * States the property of the PROJECT, not of the install, because that is the - * part no action by the user changes. An agent that reads this should stop - * offering the source rather than suggest installing anything. - */ -const NO_GITHUB_ORIGIN = "this repository has no GitHub origin"; - /** * What the host has, for the recipes that condition on it. * @@ -33,7 +20,8 @@ const NO_GITHUB_ORIGIN = "this repository has no GitHub origin"; * asks the filesystem whether a file of that name exists; it executes nothing. * That is the whole property being bought. A detection pass that spawns * arbitrary binaries found on a user's `PATH` is a far larger promise than any - * recipe here needs, and it is not one this CLI makes. + * recipe here needs, and it is not one this CLI makes. Whether a tool is + * signed in is the agent's question to ask, through {@link detectAgentHints}. * * A verification tier was considered and rejected on measurement rather than * on principle: GitHub publishes sha256 digests for `gh`'s release archives @@ -48,13 +36,20 @@ const NO_GITHUB_ORIGIN = "this repository has no GitHub origin"; * "is this GitHub" probe that could disagree with it. That call never throws: * a directory that is not a repository, a repository with no `origin`, a * non-GitHub `origin` and a host with no `git` all resolve to the - * {@link UNKNOWN_GH_OWNER} sentinel. + * {@link UNKNOWN_GH_OWNER} sentinel. `glab` asks {@link isGitLabOrigin}, which + * never throws either. */ export async function detectHostTools(cwd: string): Promise { - const { ghOwner } = await resolveRepositoryContext(cwd); - const onGitHub = ghOwner !== UNKNOWN_GH_OWNER; + const [{ ghOwner }, onGitLab] = await Promise.all([ + resolveRepositoryContext(cwd), + isGitLabOrigin(cwd), + ]); + const forgeMatches = { + github: ghOwner !== UNKNOWN_GH_OWNER, + gitlab: onGitLab, + }; - return DETECTED_TOOLS.map((name) => { + return HOST_TOOL_CATALOG.map((entry) => { // `executableName` first, ALWAYS. `findOnPath` matches a `PATH` entry // joined with the literal string and consults no `PATHEXT`, so a bare // `"gh"` finds nothing on Windows, where the file is `gh.exe`. The failure @@ -62,19 +57,42 @@ export async function detectHostTools(cwd: string): Promise { // installed — which the recipe then renders as "install the GitHub CLI", // the one instruction that cannot help them. CI runs `ubuntu-latest` // only, so nothing else catches it. - const path = findOnPath(executableName(name)); + const path = findOnPath(executableName(entry.name)); + // A tool tied to a forge is useful only on that forge; the rest are + // applicable wherever they are installed. + const applicable = + entry.forge === undefined ? true : forgeMatches[entry.forge]; return { - name, + name: entry.name, present: path !== undefined, ...(path === undefined ? {} : { path }), - // `gh` is the only tool whose usefulness depends on the repository. - // `git` and `jq` are applicable wherever they are installed. - applicable: name === "gh" ? onGitHub : true, + applicable, // The reason travels WITH the verdict, because the renderer must not - // infer one. It is the detector that knows why `gh` is inapplicable, and - // the phrasing is a fragment so a consumer can set it in parentheses or - // in a sentence of its own. - ...(name === "gh" && !onGitHub ? { reason: NO_GITHUB_ORIGIN } : {}), + // infer one. It is the detector that knows why a tool is inapplicable, + // and the phrasing is a fragment so a consumer can set it in + // parentheses or in a sentence of its own. + ...(!applicable && entry.inapplicableReason !== undefined + ? { reason: entry.inapplicableReason } + : {}), }; }); } + +/** + * Commands the agent may choose to run, keyed by command, for each tool that + * is present and applicable. + * + * A tool that is missing or useless here gets no hint: a command the agent + * cannot run, or one whose answer cannot matter, is a turn spent for nothing. + */ +export function detectAgentHints(tools: HostTool[]): Record { + const hints: Record = {}; + for (const tool of tools) { + if (!tool.present || !tool.applicable) continue; + const hint = HOST_TOOL_CATALOG.find( + (entry) => entry.name === tool.name + )?.hint; + if (hint !== undefined) hints[hint.command] = hint.meaning; + } + return hints; +} diff --git a/packages/cli/src/detect/scan.ts b/packages/cli/src/detect/scan.ts index 1e7528e9..1bf8e077 100644 --- a/packages/cli/src/detect/scan.ts +++ b/packages/cli/src/detect/scan.ts @@ -65,6 +65,11 @@ const IGNORED_DIRECTORIES: ReadonlySet = new Set([ ".pytest_cache", ".tox", ".gradle", + // Taskless's own state. It holds an `sgconfig.yml` and a `.vale.ini` that + // Taskless writes for itself, which are not the repository's linters: an + // unpruned walk reports every installed repository as already running + // ast-grep and Vale. + ".taskless", ]); /** @@ -244,6 +249,14 @@ const LINTER_SIGNALS: readonly LinterSignal[] = [ configFiles: [".swiftlint.yml", ".swiftlint.yaml"], }, { name: "checkstyle", languages: ["Java"], configFiles: ["checkstyle.xml"] }, + // Polyglot and prose linters: neither says anything about which languages + // the repository is written in, so they contribute none. + { + name: "ast-grep", + languages: [], + configFiles: ["sgconfig.yml", ".sgconfig.yml"], + }, + { name: "vale", languages: [], configFiles: [".vale.ini", "_vale.ini"] }, ]; /** diff --git a/packages/cli/src/schemas/detect.ts b/packages/cli/src/schemas/detect.ts index 531dbded..39d09b98 100644 --- a/packages/cli/src/schemas/detect.ts +++ b/packages/cli/src/schemas/detect.ts @@ -32,6 +32,27 @@ const detectedAutomationSchema = z.object({ ), }); +/** + * A command-line tool looked for on the host's `PATH`. Presence only: the file + * was found, nothing was run. + */ +const hostToolSchema = z.object({ + name: z.string().describe("The command as typed, e.g. gh, glab, git, jq"), + present: z + .boolean() + .describe("A file of this name is on PATH; Taskless did not run it"), + path: z.string().optional().describe("Where it was found, when present"), + applicable: z + .boolean() + .describe( + "The tool could accomplish something in this repository (gh needs a GitHub origin, glab a GitLab one)" + ), + reason: z + .string() + .optional() + .describe("Why the tool is not applicable, when it is not"), +}); + /** Output schema for `taskless detect --json` on success */ export const outputSchema = z.object({ success: z.literal(true), @@ -52,6 +73,14 @@ export const outputSchema = z.object({ .describe( "Tools that run commands at commit time (hook managers and lint-staged), configured at the scan root" ), + hostTools: z + .array(hostToolSchema) + .describe("Command-line tools looked for on PATH, in a fixed order"), + agent: z + .record(z.string(), z.string()) + .describe( + "Commands an agent may choose to run, keyed by command, each with what it tells the agent. Only present, applicable tools get one; detect runs none of them" + ), }); // On the (internal-only) error path `detect` emits the standard diff --git a/packages/cli/src/util/git-remote.ts b/packages/cli/src/util/git-remote.ts index 2ab337b4..dffebf44 100644 --- a/packages/cli/src/util/git-remote.ts +++ b/packages/cli/src/util/git-remote.ts @@ -90,6 +90,38 @@ export async function resolveRepositoryContext( } } +/** + * Whether `origin` is a GitLab remote: `gitlab.com`, or a self-hosted instance + * whose host name begins with `gitlab.`. + * + * Never rejects, for the reason {@link resolveRepositoryContext} does not: a + * caller reporting what a host can do wants a verdict, not an exception. A + * self-hosted GitLab under any other name reads as not GitLab, which costs a + * source rather than claiming one that is not there. + * + * GitHub is NOT decided here. That verdict belongs to + * {@link resolveRepositoryContext}, so remote generation, `info`, telemetry and + * host detection cannot disagree about it. + */ +export async function isGitLabOrigin(cwd: string): Promise { + let rawUrl: string; + try { + rawUrl = await getOriginUrl(cwd); + } catch { + return false; + } + // `canonicalOwnerUrl` falls back to `github.com` for an input with no host, + // such as a local path remote. That default can only ever produce a + // non-GitLab answer, which is the correct one for a path. + let host: string; + try { + host = new URL(canonicalOwnerUrl(rawUrl)).hostname; + } catch { + return false; + } + return host === "gitlab.com" || host.startsWith("gitlab."); +} + function getOriginUrl(cwd: string): Promise { return new Promise((resolve, reject) => { execFile( diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 73a6c6e7..e58894ea 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -41,6 +41,7 @@ describe("cli", () => { "gh", "git", "jq", + "glab", ]); // `git` is how this test suite got here, so it is a safe assertion // about a real host rather than about a fixture. diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts index cef66282..d39e4530 100644 --- a/packages/cli/test/detect.test.ts +++ b/packages/cli/test/detect.test.ts @@ -50,6 +50,14 @@ interface DetectJson { ruleStyles: { source: string; description: string }[]; ci: { name: string; evidence: string[] }[]; hooks: { name: string; evidence: string[] }[]; + hostTools: { + name: string; + present: boolean; + path?: string; + applicable: boolean; + reason?: string; + }[]; + agent: Record; } async function detect(cwd: string): Promise { @@ -271,8 +279,10 @@ describe("taskless detect", () => { const result = await detect(cwd); expect(Object.keys(result).toSorted()).toEqual( [ + "agent", "ci", "hooks", + "hostTools", "languages", "linters", "ruleStyles", @@ -287,6 +297,62 @@ describe("taskless detect", () => { } }); + it("detects ast-grep from sgconfig.yml", async () => { + await writeFile(join(cwd, "sgconfig.yml"), "ruleDirs: []\n", "utf8"); + const result = await detect(cwd); + expect(result.linters).toContainEqual({ + name: "ast-grep", + evidence: ["sgconfig.yml"], + }); + // Polyglot: it says nothing about what the repository is written in. + expect(result.languages).toEqual([]); + }); + + it("detects vale from .vale.ini", async () => { + await writeFile(join(cwd, ".vale.ini"), "StylesPath = styles\n", "utf8"); + const result = await detect(cwd); + expect(result.linters).toContainEqual({ + name: "vale", + evidence: [".vale.ini"], + }); + }); + + // Taskless writes its own `sgconfig.yml` and `.vale.ini` under `.taskless/`. + // Reading those as the repository's linters would report every installed + // repository as already running both. + it("does not read Taskless's own configuration as the repository's linters", async () => { + await mkdir(join(cwd, ".taskless"), { recursive: true }); + await writeFile(join(cwd, ".taskless", "sgconfig.yml"), "", "utf8"); + await writeFile(join(cwd, ".taskless", ".vale.ini"), "", "utf8"); + const result = await detect(cwd); + expect(linterNames(result)).not.toContain("ast-grep"); + expect(linterNames(result)).not.toContain("vale"); + }); + + it("reports host tools in a fixed order, and hints only for usable ones", async () => { + // Not a repository, so neither forge tool can apply here whatever is + // installed on the machine running the tests. + const result = await detect(cwd); + expect(result.hostTools.map((tool) => tool.name)).toEqual([ + "gh", + "git", + "jq", + "glab", + ]); + expect(result.hostTools.find((tool) => tool.name === "gh")).toMatchObject({ + applicable: false, + reason: "this repository has no GitHub origin", + }); + expect(result.hostTools.find((tool) => tool.name === "glab")).toMatchObject( + { + applicable: false, + reason: "this repository has no GitLab origin", + } + ); + expect(Object.keys(result.agent)).not.toContain("gh auth status"); + expect(Object.keys(result.agent)).not.toContain("glab auth status"); + }); + it("does not false-positive a pyproject table on a similarly-prefixed sibling", async () => { // `[tool.ruff-lsp]` must NOT be read as the `ruff` tool table. await writeFile( diff --git a/packages/cli/test/host-tools.test.ts b/packages/cli/test/host-tools.test.ts index 1d363b38..cf8c2351 100644 --- a/packages/cli/test/host-tools.test.ts +++ b/packages/cli/test/host-tools.test.ts @@ -1,11 +1,11 @@ import { execFile } from "node:child_process"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; -import { detectHostTools } from "../src/detect/host-tools"; +import { detectAgentHints, detectHostTools } from "../src/detect/host-tools"; import { getRecipe, type HostTool } from "../src/prompts/recipes"; const execFileAsync = promisify(execFile); @@ -47,7 +47,7 @@ describe("detectHostTools", () => { const tools = await detectHostTools(cwd); - expect(tools.map((tool) => tool.name)).toEqual(["gh", "git", "jq"]); + expect(tools.map((tool) => tool.name)).toEqual(["gh", "git", "jq", "glab"]); for (const tool of tools) { expect(tool.present).toBe(false); // No `path` key at all rather than an empty string: a consumer @@ -68,6 +68,48 @@ describe("detectHostTools", () => { expect(tools.find((tool) => tool.name === "jq")?.applicable).toBe(true); }); + it("marks glab applicable on a GitLab origin and inapplicable elsewhere", async () => { + cwd = await makeRepository("git@gitlab.com:acme/widgets.git"); + const onGitLab = await detectHostTools(cwd); + expect(onGitLab.find((tool) => tool.name === "glab")?.applicable).toBe( + true + ); + expect(onGitLab.find((tool) => tool.name === "gh")?.applicable).toBe(false); + await rm(cwd, { recursive: true, force: true }); + + cwd = await makeRepository("https://github.com/acme/widgets.git"); + const onGitHub = await detectHostTools(cwd); + expect(onGitHub.find((tool) => tool.name === "glab")).toMatchObject({ + applicable: false, + reason: "this repository has no GitLab origin", + }); + }); + + it("treats a self-hosted gitlab.* instance as GitLab", async () => { + cwd = await makeRepository("https://gitlab.example.com/acme/widgets.git"); + const tools = await detectHostTools(cwd); + expect(tools.find((tool) => tool.name === "glab")?.applicable).toBe(true); + }); + + it("runs nothing it finds: a planted gh that would fail is still only present", async () => { + // A file named `gh` that exits non-zero if executed. Detection reporting it + // as present without erroring is the evidence it was looked at, not run. + cwd = await makeRepository("https://github.com/acme/widgets.git"); + await writeFile(join(cwd, "gh"), "#!/bin/sh\nexit 7\n", { + encoding: "utf8", + mode: 0o755, + }); + // Prepended, not replaced: `git` must stay reachable for the origin to + // resolve, and the planted file must be the `gh` found first. + process.env.PATH = `${cwd}${delimiter}${originalPath ?? ""}`; + + const tools = await detectHostTools(cwd); + expect(tools.find((tool) => tool.name === "gh")).toMatchObject({ + present: true, + applicable: true, + }); + }); + it("marks gh inapplicable in a directory that is not a repository", async () => { cwd = await mkdtemp(join(tmpdir(), "taskless-host-tools-bare-")); @@ -327,3 +369,34 @@ describe("the prompts export renders the full menu with no options", () => { expect(rendered).not.toContain("(Linear, Jira, GitHub issues via"); }); }); + +describe("detectAgentHints", () => { + it("hints only for tools that are present and applicable", () => { + const hints = detectAgentHints([ + { name: "gh", present: true, applicable: true }, + { name: "git", present: true, applicable: true }, + { name: "jq", present: false, applicable: true }, + { + name: "glab", + present: true, + applicable: false, + reason: "this repository has no GitLab origin", + }, + ]); + expect(Object.keys(hints)).toEqual(["gh auth status"]); + expect(hints["gh auth status"]).toContain("signed in to GitHub"); + }); + + it("tells the agent to read glab's output rather than its exit code", () => { + const hints = detectAgentHints([ + { name: "glab", present: true, applicable: true }, + ]); + expect(hints["glab auth status"]).toContain("not the exit code"); + }); + + it("gives no hint for an absent tool", () => { + expect( + detectAgentHints([{ name: "gh", present: false, applicable: true }]) + ).toEqual({}); + }); +}); From 0727cb9e5ea0be9d05e6605d8d110a2cfcc13894 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 13 Oct 2026 18:54:24 -0700 Subject: [PATCH 3/3] refactor(cli): drop the unused OriginForge type from the host-tool catalog --- packages/cli/src/detect/host-tool-catalog.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/cli/src/detect/host-tool-catalog.ts b/packages/cli/src/detect/host-tool-catalog.ts index ce1201e6..77d21ab7 100644 --- a/packages/cli/src/detect/host-tool-catalog.ts +++ b/packages/cli/src/detect/host-tool-catalog.ts @@ -12,9 +12,6 @@ * is meant to read this table too. */ -/** The forge an `origin` remote lives on, as far as host tools care. */ -export type OriginForge = "github" | "gitlab" | "other" | "none"; - export interface HostToolEntry { /** The command as it would be typed, e.g. `gh`. */ name: string;