diff --git a/.changeset/onboard-command.md b/.changeset/onboard-command.md new file mode 100644 index 0000000..2f9d2b1 --- /dev/null +++ b/.changeset/onboard-command.md @@ -0,0 +1,11 @@ +--- +"@taskless/cli": minor +--- + +Add `taskless onboard` post-install discovery flow and migrate recipe rendering to sprintf-js. + +- **`taskless onboard` subcommand**: a thin gate that prints an agent-facing recipe walking the host AI tool through mining the codebase, agent-memory files (CLAUDE.md / AGENTS.md / .cursorrules), recent PR review comments (via `gh`), and issue-tracker tickets (via MCP) for high-signal rule candidates. Output is a bullet list the user can materialize via `taskless rule create`. Three modes: default prints the recipe (refused if already complete), `--force` re-runs regardless of state, `--mark-complete` writes `install.onboarded: true` (invoked only by the agent after explicit user confirmation). `--force` and `--mark-complete` are mutually exclusive. +- **`install.onboarded` manifest field**: optional 3-state boolean (absent / `false` / `true`) added to `.taskless/taskless.json`. `taskless init` never writes it; only `taskless onboard --mark-complete` does. Re-installs preserve the existing value. +- **Post-install onboarding trailer**: after a successful `taskless init` (both wizard and `--no-interactive` paths), the CLI prints a one-line trailer pointing the user at the new flow. Wording adapts to the install plan: when commands were installed (Claude Code, Cursor), the trailer mentions `/tskl onboard`, the Taskless skill, and `taskless onboard`; when no commands were installed (OpenCode, Codex, `.agents/` fallback), it mentions the skill and the CLI only. `taskless update` does not print the trailer. +- **Skill description trigger expanded**: the consolidated `taskless` skill now also volunteers Taskless when the user asks to add/write/create a rule and has NOT named a specific lint/format/static-analysis tool. Suppressing examples (illustrative — any named tool of this kind suppresses): `eslint`, `ruff`, `biome`, `ast-grep`. Behavior on this trigger is a quiet single-line offer, not a full recipe; declines are sticky within the conversation only and never written to disk. Replaces the prior blanket "do NOT trigger on generic ESLint/linting" carve-out. +- **Recipe substitution refactor**: recipe rendering switched from ad-hoc `{{KEY}}` `replaceAll` calls to `sprintf-js` named arguments. All recipes now use `%(KEY)s` placeholders. `CLI_VERSION` and `INPUT_SCHEMA` continue to resolve to system-rendered values; `PACKAGE_MANAGER_DLX` joins them as an "agent-fill" marker rendered as ``. Recipes that contain literal `%` characters must escape as `%%` per sprintf-js conventions. diff --git a/.claude/settings.json b/.claude/settings.json index 1ac8c98..a5582f9 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -27,7 +27,9 @@ "mcp__linear__*", "Bash(gh pr:*)", "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\({k: d['summary'][k] for k in ['high','medium','low','resolved','needs_attention']}\\)\\)\")", - "Bash(find skills -name \"SKILL.md\" -exec grep -l \"optional\\\\|required\" {} \\\\;)" + "Bash(find skills -name \"SKILL.md\" -exec grep -l \"optional\\\\|required\" {} \\\\;)", + "Skill(pr-writer)", + "Skill(pr-writer:*)" ], "deny": [ "AskUserQuestion*", diff --git a/openspec/changes/archive/2026-05-15-add-onboard-command/.openspec.yaml b/openspec/changes/archive/2026-05-15-add-onboard-command/.openspec.yaml new file mode 100644 index 0000000..66dd08a --- /dev/null +++ b/openspec/changes/archive/2026-05-15-add-onboard-command/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-14 diff --git a/openspec/changes/archive/2026-05-15-add-onboard-command/design.md b/openspec/changes/archive/2026-05-15-add-onboard-command/design.md new file mode 100644 index 0000000..ecacfdd --- /dev/null +++ b/openspec/changes/archive/2026-05-15-add-onboard-command/design.md @@ -0,0 +1,140 @@ +## Context + +Today's CLI gets users _installed_ through `taskless init` (a fast, deterministic wizard) and exposes per-task agent flows via the consolidated `taskless` skill, which routes the host agent to recipes fetched on demand from `npx @taskless/cli help `. There is no path between "installed" and "actively producing rules from real codebase signal" — first-time users typically don't know what kinds of rules Taskless is well-suited to capture, what sources to mine for candidates, or how to prioritize them. + +Onboarding is intrinsically agent work: it requires reading the codebase, parsing AGENTS.md/CLAUDE.md/.cursorrules style documents, optionally probing GitHub PR comments via `gh`, optionally querying a Linear/Jira MCP, and synthesizing a prioritized list of _hypothetical_ rules that the user can choose to materialize. The CLI cannot do any of that itself — it has no LLM. So `onboard` follows the same shape as the other ex-skill recipes: a thin CLI surface that gates on state and delivers a recipe the host agent executes. + +The change also reverses a deliberate prior decision: the current `skill-taskless` description explicitly tells the agent _not_ to trigger on generic linting or rule requests. The new design widens the trigger to volunteer Taskless quietly when the user wants to add a rule and has not named another tool. This is intentional but it carries a real reputation cost if it manifests as advertising rather than helpfulness, so the design is opinionated about _how_ the volunteering shows up. + +## Goals / Non-Goals + +**Goals:** + +- Provide a `taskless onboard` subcommand with a help topic that delivers a conversational, agent-executed recipe for first-pass rule discovery. +- Track onboarding state in `.taskless/taskless.json` via an `onboarded` field that the agent writes only after explicit user confirmation. +- Expand the `taskless` skill so it volunteers Taskless on rule requests where no other tool is named. +- Degrade gracefully when external tools (`gh`, MCPs) are absent — onboarding must produce value with only codebase + agent-memory files. +- Keep the always-loaded skill surface small (description + body together stay within the existing constraints). + +**Non-Goals:** + +- This change does NOT introduce automated rule generation. The recipe surfaces _suggestions_ as bullet points; the user (with the agent's help) chooses which to materialize via the existing `/tskl create rule` flow. +- This change does NOT add any persistent decline state. A user who declines the proactive suggestion in a conversation is not remembered across conversations. +- This change does NOT modify any existing recipe content (check, rule create/improve/delete, auth, ci, init). Those topics are unchanged. +- This change does NOT introduce a separate slash command. Routing is via the existing `/tskl` consolidated command + the new `onboard` topic in the skill router. +- This change does NOT enumerate every linter on earth in the trigger description; the description uses spirit-of-the-rule wording with four anchor examples. + +## Decisions + +### Decision 1: Recipe lives in `help/onboard.txt`; CLI surface is a thin gate + +`taskless onboard` is implemented as a small subcommand handler that: + +1. Reads `.taskless/taskless.json` (bootstrapping the directory if absent). +2. If `onboarded === true` and `--force` is not set, prints "Already onboarded; pass --force to redo" and exits 0. +3. Otherwise, prints the contents of `onboard.txt` (the recipe) to stdout and exits 0. + +A `--mark-complete` flag (mutually exclusive with the default mode) writes `onboarded: true` to the manifest and exits 0. The recipe instructs the agent to invoke `taskless onboard --mark-complete` only after the user has explicitly confirmed they're done. + +**Why not just `taskless help onboard`?** + +Two reasons. First, `taskless help ` is a generic recipe-printer with no awareness of state — it would print the recipe regardless of whether the user is already onboarded. Putting the gate in a dedicated subcommand keeps the help surface generic and the gating logic in one place. Second, the `--mark-complete` write needs _some_ CLI surface; bundling it under `taskless onboard` keeps the verb's surface area cohesive ("everything you can do about onboarding lives here"). + +`taskless help onboard` SHALL still work and SHALL still print the same recipe content — there's no special variant. The CLI subcommand and the help topic share the embedded text. + +**Alternatives considered:** + +- Put the gate logic inside the recipe (agent reads `taskless.json` itself). Rejected: forces every host agent to re-implement gate logic; deterministic state checks belong in the CLI. +- Make the agent edit `taskless.json` directly to mark onboarded. Rejected: write logic spreads across CLI and agent; harder to evolve the schema; harder to test. + +### Decision 2: `onboarded` is a 3-state optional boolean field + +The `taskless.json` `install` object gains an optional `onboarded?: boolean` field with three meaningful states: + +| Value | Meaning | When the gate fires | +| ------- | ------------------------------------------------- | ------------------------------- | +| absent | Never explicitly onboarded (post-install default) | Recipe runs | +| `false` | Explicitly declined or reset | Recipe runs | +| `true` | User confirmed they're done | Recipe refused unless `--force` | + +The bootstrap install (`taskless init`) does NOT set this field. The field is only ever written by `taskless onboard --mark-complete` after the agent has explicit user confirmation, mirroring the `--anonymous` consent pattern. There is no separate decline state — declining is a per-conversation behavior governed by the skill, not persisted. + +**Why not a richer enum (`unset`/`in-progress`/`declined`/`done`)?** + +Two reasons. First, none of the other states unlock distinct behavior — only "done vs. not done" affects the gate. Second, the simpler shape avoids a future migration if we ever need to add states; the boolean is forward-compatible because the field is optional. + +### Decision 3: `--force` overrides any value of `onboarded` + +`taskless onboard --force` runs the recipe regardless of state — works whether `onboarded` is absent, `false`, or `true`. The flag is documented but not advertised in the standard recipe; the CLI's "already onboarded" message mentions it as the override. + +This avoids the pitfall where `--force` only works in the `true` state — if a user wants to re-run onboarding for any reason (new codebase, new tools available), one consistent flag should always work. + +### Decision 4: Skill description trigger is widened with named-tool suppression + +The `skill-taskless` description gains a new clause: + +> Also trigger when the user asks to add/write/create a rule and does NOT name a specific lint/format/static-analysis tool. Examples of named tools that suppress this trigger: eslint, ruff, biome, ast-grep. The list is illustrative — any named lint/format/static-analysis tool suppresses the trigger. + +Behavior on this trigger is **quiet suggestion**, not an interrogation: + +- The skill SHALL surface a single-line offer ("I can capture this as a Taskless rule if you want — say so, or I'll proceed with X"). +- If the user declines or ignores the offer, the agent SHALL proceed with whatever it would have done without the skill, and SHALL NOT re-offer in the same conversation. +- If the user accepts, the skill router takes over normally and proceeds via `npx @taskless/cli help rule create`. + +**Why not a fixed allowlist of suppressing tool names?** + +The CLI release cycle can't keep up with the long tail of linters/formatters/SAST tools (ruff, oxlint, knip, semgrep, clippy, golangci-lint, stylelint, rubocop, etc.). A fixed list is either too short (false-positive volunteers when users name a tool we forgot) or too long (eats the description's 1024-char budget). Spirit-of-the-rule wording with four named examples (eslint = JS/TS classic, ruff = Python, biome = modern unified, ast-grep = semantic/structural) gives the model both a heuristic and concrete pattern-matching anchors. + +**Why intentionally widen a trigger we previously narrowed?** + +The prior narrowing was correct _for users who already knew about Taskless and didn't want it suggested_. With the proactive-onboard goal, we accept a regression on that audience in exchange for helping users who installed Taskless but never used it productively. The mitigation set (quiet wording, named-tool suppression, in-conversation sticky decline) bounds the cost. + +### Decision 5: Recipe is conversational, not a fixed phase script + +The `onboard.txt` recipe instructs the agent to: + +1. Open with a short menu of _known_ sources for rule candidates: TODOs/FIXMEs (ripgrep), agent-memory files (CLAUDE.md / AGENTS.md / .cursorrules / etc.), recent PR review comments (if `gh` is available), issue-tracker tickets (if a relevant MCP is detected). +2. Encourage the user to name additional sources the agent wouldn't know about (e.g., a team wiki page, a specific doc). +3. For each chosen source, scan and filter for _high-signal_ candidates — patterns that appear repeatedly, comments that cite a doc/style guide, things that block PR merges. Filter out one-off nits and pure formatting. +4. Synthesize a bullet list where each bullet is a hypothetical rule: a kebab-case name and a one-line description of what it would enforce. +5. For each bullet, offer to materialize it via the existing `/tskl create rule` flow. +6. At the end, ask the user whether they consider onboarding complete; on yes, run `taskless onboard --mark-complete`. + +**Why conversational over fixed phases?** + +The available sources differ wildly across users (some have GitHub, some have GitLab, some have neither; some use Linear, some Jira, some nothing; some have rich CLAUDE.md, some don't). A fixed phase script would either skip valuable sources for users who have them or run irrelevant phases for users who don't. The agent can see its own tool list — it knows what MCPs are available without us encoding that. Letting the agent + user decide together produces better signal-to-noise than a one-size-fits-all script. + +### Decision 6: Init prints a one-line trailer to nudge onboarding + +After the install summary, `taskless init` prints a single line such as: + +``` +Next: run /tskl onboard in your AI tool to discover rule candidates from your codebase. +``` + +This is added to both the wizard and the non-interactive path. It is not gated on the absence of `onboarded` — the trailer is informational, not an enforced flow. We don't want to fail an install just because the manifest reads `onboarded: true` from a previous run. + +## Risks / Trade-offs + +- **Risk: The widened trigger feels like advertising.** → Mitigation: enforce _quiet single-line_ suggestion wording in the skill body; in-conversation sticky decline; named-tool suppression. If real-world feedback shows the trigger is still annoying, the skill description can be re-narrowed in a follow-up without changing the rest of the change. + +- **Risk: The recipe takes a long time on large repos.** → Mitigation: the recipe is conversational — the agent confirms scope with the user before running long scans. The agent SHOULD show progress on multi-source scans (a status line per source, not per file). + +- **Risk: PR-comment scanning surfaces noisy candidates.** → Mitigation: the recipe explicitly instructs the agent to filter for _repeated patterns_, _cited docs_, and _merge-blocking comments_, not for one-off nits. The bullet output makes it easy for the user to decline candidates. + +- **Risk: A user who manually edited `taskless.json` to set `onboarded: true` later wants to re-run.** → Mitigation: documented `--force` flag; the "already onboarded" message tells them about it. + +- **Trade-off: No persistent "declined" state.** Users who decline the proactive suggestion will be re-asked in future conversations. Accepted: storing per-user decline state is more complexity than it's worth, and the suggestion is intentionally low-friction. + +- **Trade-off: Recipe success depends on agent quality.** A weaker host agent will produce weaker rule suggestions. We accept this — the alternative (encoding a deterministic suggestion engine in the CLI) defeats the point of the agent-skills architecture. + +## Migration Plan + +This change is additive at the schema level and observable-but-non-breaking at the trigger level. Migration steps: + +1. **Schema**: The `onboarded` field is new and optional; no `taskless.json` migration is required (existing manifests with no `onboarded` field read as "not onboarded" naturally). No bump to the migrate-runner version is needed. +2. **Skill**: Existing installs do not auto-update. Users who upgrade the CLI (or run `taskless update`) will pick up the new skill body and description on the next install. Users who never re-run init will keep the v0.7.0 skill — that's acceptable; the `onboard` command works regardless. +3. **Help**: The `onboard` topic appears in the `taskless help` index after the new build is released. +4. **Init trailer**: Appears on the next CLI release; existing installs are unaffected until they re-run init. + +No rollback complexity beyond standard "publish a previous CLI version." diff --git a/openspec/changes/archive/2026-05-15-add-onboard-command/proposal.md b/openspec/changes/archive/2026-05-15-add-onboard-command/proposal.md new file mode 100644 index 0000000..5990bb8 --- /dev/null +++ b/openspec/changes/archive/2026-05-15-add-onboard-command/proposal.md @@ -0,0 +1,34 @@ +## Why + +First-time users who install Taskless don't know what it can do or what kinds of rules it's well-suited to capture, leading to underutilization. The CLI today gets users _installed_ (via `taskless init`) but offers no guided path from "installed" to "actively producing rules from real codebase signal." We need a post-install onboarding flow that uses the host agent's own context (codebase, PR history, issue tracker, agent memory files) to surface high-value rule candidates and walk the user from zero rules to a useful starter set. + +## What Changes + +- New `taskless onboard` subcommand backed by a new `cli-onboard` capability. Like other ex-skill recipes, the command surface is a thin help-topic delivery: the CLI prints the recipe and the host agent executes it. +- New help topic `onboard` registered with the help index so `taskless help onboard` and `npx @taskless/cli help onboard` return the recipe. +- New `onboarded: boolean` field in `.taskless/taskless.json` (3-state: absent / `false` / `true`). Only the agent writes it, only with explicit user confirmation at the end of a successful onboarding pass. +- New `--force` flag on `taskless onboard` that re-runs the recipe regardless of the current `onboarded` value. +- Skill router (`taskless` skill) gains an `onboard` row in its disambiguation table so `/tskl onboard` routes to the new recipe. +- **BREAKING (skill description trigger)**: The skill description trigger is expanded. The current spec instructs the agent to NOT trigger on generic linting/rule requests; the new behavior is to volunteer Taskless quietly when the user says "add/write/create a rule" and does not name a specific lint/format/static-analysis tool. A short example list (eslint, ruff, biome, ast-grep) is included as suppression hints; the underlying heuristic is "any named lint/format/static-analysis tool suppresses the trigger." In-conversation declines are sticky; no persistent decline state is stored. +- `taskless init` prints a one-line trailer after the install summary pointing the user at `/tskl onboard`. + +## Capabilities + +### New Capabilities + +- `cli-onboard`: The `taskless onboard` subcommand surface, the `onboard` help topic, the `--force` flag, the recipe shape and content (conversational discovery, source enumeration, bullet-list rule suggestions, end-of-recipe permission to mark onboarded), and the rules governing when the agent may write the `onboarded` flag. + +### Modified Capabilities + +- `cli-help`: Register the new `onboard` topic in the help index and ensure `taskless help onboard` returns the recipe content. +- `cli-init`: Add a one-line post-install trailer pointing users at `/tskl onboard` when install completes. +- `cli-taskless-bootstrap`: Add the optional `onboarded` field to the `.taskless/taskless.json` schema (absent ≡ false), document the 3-state semantics, and require that only the agent writes it (after user confirmation). +- `skill-taskless`: Expand the skill description trigger to include unspecified-tool rule requests; add the `onboard` row to the disambiguation table; specify the quiet suggestion behavior and in-conversation sticky decline. + +## Impact + +- **Code**: New `packages/cli/src/commands/onboard.ts` (thin subcommand handler) and `packages/cli/src/help/onboard.txt` (recipe). Updates to the help index registration. Update to `taskless init`'s post-install output. Update to the skill description and body in `skills/taskless/SKILL.md`. +- **Schema**: `.taskless/taskless.json` schema gains an optional `onboarded` field. The bootstrap install does not set it; only the agent writes it. +- **Skills surface**: The skill description's trigger language widens. This is an intentional reversal of a prior deliberate narrowing. Risk: agents may volunteer Taskless on requests where the user did not want it. Mitigation: quiet single-line suggestion + named-tool suppression + in-conversation sticky decline. +- **External tooling assumptions**: The recipe assumes (but does not require) `gh` CLI for PR scanning and Linear/Jira/etc. MCP servers for issue scanning. The recipe degrades gracefully when these are absent and asks the user to suggest other sources. +- **Runtime dependencies**: adds `sprintf-js` to `@taskless/cli` (and `@types/sprintf-js` as a dev dependency) for the recipe-substitution refactor. No other new dependencies. diff --git a/openspec/changes/archive/2026-05-15-add-onboard-command/specs/cli-help/spec.md b/openspec/changes/archive/2026-05-15-add-onboard-command/specs/cli-help/spec.md new file mode 100644 index 0000000..e4aac78 --- /dev/null +++ b/openspec/changes/archive/2026-05-15-add-onboard-command/specs/cli-help/spec.md @@ -0,0 +1,93 @@ +## MODIFIED Requirements + +### Requirement: Help text files follow a consistent format + +Every help text file at `packages/cli/src/help/.txt` SHALL follow the canonical recipe template: a single-line header `# Topic: (CLI v%(CLI_VERSION)s / topic v)`, followed by `## Goal`, `## Preconditions`, `## Steps`, optional `## Input schema` (for recipes that take `--from`), `## Errors`, and `## See Also` sections in that order. Recipe templates SHALL use sprintf-js `%(KEY)s` named-argument placeholders for all substitution. The header SHALL embed `%(CLI_VERSION)s` for the CLI version. Topics that document a `--from` input SHALL embed `%(INPUT_SCHEMA)s` inside the `## Input schema` fenced code block. The topic version integer in the header SHALL be a literal value maintained by the recipe author and bumped when the recipe changes meaningfully. + +#### Scenario: Recipe contains all template sections + +- **WHEN** any `.txt` file is read +- **THEN** it SHALL begin with a `# Topic:` header containing `%(CLI_VERSION)s` and the topic version integer +- **AND** SHALL contain `## Goal`, `## Preconditions`, `## Steps`, `## Errors`, and `## See Also` sections in that order + +#### Scenario: Recipe with --from input includes JSON schema placeholder + +- **WHEN** a topic recipe documents a CLI invocation that uses `--from ` +- **THEN** the recipe SHALL contain an `## Input schema` section with a code-fenced block containing the `%(INPUT_SCHEMA)s` placeholder +- **AND** the JSON Schema SHALL be derived at render time from the corresponding Zod schema in `packages/cli/src/schemas/` + +#### Scenario: Header version reflects build-time CLI version + +- **WHEN** the CLI bundle is built +- **THEN** the recipe header's `%(CLI_VERSION)s` placeholder SHALL be substituted at render time from `packages/cli/package.json` +- **AND** SHALL match the version reported by `taskless info` + +## ADDED Requirements + +### Requirement: Recipe substitution uses sprintf-js named arguments + +Recipe rendering SHALL substitute placeholders via `sprintf-js` using its named-argument form (`%(KEY)s`). The renderer SHALL build a variables table for each render call containing two flavors of substitution: + +1. **System-resolved values** — keys whose values come from runtime state. The renderer SHALL provide `CLI_VERSION` (resolved from the build-time version constant) for every render. The renderer SHALL provide `INPUT_SCHEMA` only when the recipe content contains the `%(INPUT_SCHEMA)s` placeholder; the value is the JSON Schema rendered from the topic's Zod schema in `packages/cli/src/schemas/`, or the literal string `"(no input schema for this topic)"` when no Zod schema is registered for the topic. +2. **Agent-fill markers** — keys whose values render as a lowercase angle-bracket token of the same name (e.g. `PACKAGE_MANAGER_DLX` renders as ``). The renderer SHALL provide `PACKAGE_MANAGER_DLX` for every render. Agent-fill markers exist so the consuming agent can substitute the value at execution time without the recipe having to invent a per-recipe placeholder convention. + +Recipe authors SHALL escape any literal `%` character in recipe content as `%%` per sprintf-js conventions. The renderer SHALL NOT introduce any other placeholder syntax (`{{KEY}}`, `${KEY}`, etc.); all substitution SHALL flow through the sprintf-js named-argument table. + +#### Scenario: CLI_VERSION substitutes the build-time version + +- **WHEN** any recipe is rendered +- **THEN** every `%(CLI_VERSION)s` occurrence SHALL be replaced with the build-time CLI version + +#### Scenario: INPUT_SCHEMA substitutes only when present in the recipe + +- **WHEN** a recipe contains `%(INPUT_SCHEMA)s` +- **THEN** it SHALL be replaced with the JSON Schema rendered from the topic's Zod schema +- **AND** when no Zod schema is registered for the topic, the placeholder SHALL render as `(no input schema for this topic)` + +#### Scenario: PACKAGE_MANAGER_DLX renders as an agent-fill marker + +- **WHEN** any recipe contains `%(PACKAGE_MANAGER_DLX)s` +- **THEN** the rendered output SHALL contain the literal token `` at every occurrence + +#### Scenario: No legacy placeholder syntax remains in recipes + +- **WHEN** any `.txt` file under `packages/cli/src/help/` is read +- **THEN** it SHALL NOT contain a `{{KEY}}` mustache-style placeholder +- **AND** all substitution SHALL be expressed as `%(KEY)s` sprintf-js named arguments + +### Requirement: onboard topic is registered in the help index + +A new help topic `onboard` SHALL be registered. The CLI SHALL embed `packages/cli/src/help/onboard.txt` at build time via the existing `import.meta.glob` mechanism. `taskless help onboard` SHALL print the contents of `onboard.txt`. The topic SHALL appear in the output of `taskless help` (the index) with a one-line summary describing it as the post-install rule-discovery flow. + +#### Scenario: Help for onboard returns the recipe + +- **WHEN** a user runs `taskless help onboard` +- **THEN** the CLI SHALL print the contents of `onboard.txt` to stdout +- **AND** SHALL exit with code 0 + +#### Scenario: Help index includes onboard + +- **WHEN** a user runs `taskless help` (no args) +- **THEN** the topic table SHALL include a row for `onboard` +- **AND** the row SHALL describe it as the post-install rule-discovery flow + +#### Scenario: Onboard recipe is embedded at build time + +- **WHEN** the CLI bundle is built +- **THEN** `import.meta.glob` matching the help directory SHALL include `onboard.txt` +- **AND** the recipe SHALL be available at runtime without filesystem access + +#### Scenario: Help onboard with --anonymous falls back + +- **WHEN** a user runs `taskless help onboard --anonymous` +- **AND** no `onboard.anonymous.txt` exists +- **THEN** the CLI SHALL print the contents of `onboard.txt` (anonymous is a no-op for this topic) + +### Requirement: help_onboard intent telemetry + +The help command's existing intent-telemetry requirement SHALL extend naturally to the new topic: invocations of `taskless help onboard` SHALL emit a `help_onboard` PostHog event, consistent with the `help_` pattern. + +#### Scenario: Help onboard emits help_onboard + +- **WHEN** an agent runs `taskless help onboard` +- **THEN** PostHog SHALL receive a `help_onboard` event diff --git a/openspec/changes/archive/2026-05-15-add-onboard-command/specs/cli-init/spec.md b/openspec/changes/archive/2026-05-15-add-onboard-command/specs/cli-init/spec.md new file mode 100644 index 0000000..fa96a03 --- /dev/null +++ b/openspec/changes/archive/2026-05-15-add-onboard-command/specs/cli-init/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: Init prints a post-install onboarding trailer + +After a successful install (both wizard and `--no-interactive` paths), `taskless init` SHALL print a single one-line trailer pointing the user at the new onboarding flow. The trailer SHALL be printed AFTER the install summary (the lines that report what was written and any obsolete files removed) and SHALL be the final line of output before the process exits. The trailer SHALL NOT be gated on the value of `install.onboarded` in the manifest — it is informational, printed every successful install. The trailer's wording SHALL adapt to the install plan: when at least one installed target received the `tskl` slash command (Claude Code or Cursor), the trailer SHALL mention `/tskl onboard`, the Taskless skill, AND `taskless onboard` (since command-receiving tools also get the skill, both AI-tool entry points are surfaced); when no installed target received commands (OpenCode, Codex, or the `.agents/` fallback), the trailer SHALL mention only the Taskless skill and `taskless onboard`, and SHALL NOT mention `/tskl onboard`. The trailer SHALL be suppressed when `taskless init` exits non-zero (cancelled wizard, install failure, etc.) or when the install was a no-op (no targets selected, no files to write). + +#### Scenario: Wizard install with commands mentions slash command, skill, and CLI + +- **WHEN** a user runs `taskless init` in an interactive terminal and the wizard completes successfully +- **AND** at least one selected target received the `tskl` slash command (Claude Code or Cursor) +- **THEN** the final line of output SHALL be a single-line trailer that mentions `/tskl onboard`, the Taskless skill, AND `taskless onboard` + +#### Scenario: Wizard install without commands mentions skill and CLI only + +- **WHEN** a user runs `taskless init` in an interactive terminal and the wizard completes successfully +- **AND** no selected target received commands (e.g. only OpenCode and/or Codex were chosen) +- **THEN** the final line of output SHALL be a single-line trailer instructing the user to invoke the Taskless skill via natural language and mentioning `taskless onboard` as a terminal fallback +- **AND** the trailer SHALL NOT mention `/tskl onboard` + +#### Scenario: Non-interactive install with commands mentions slash command, skill, and CLI + +- **WHEN** a user runs `taskless init --no-interactive` against a project where Claude Code or Cursor is detected +- **THEN** the final line of output SHALL mention `/tskl onboard`, the Taskless skill, AND `taskless onboard` + +#### Scenario: Non-interactive install with no commands mentions skill and CLI only + +- **WHEN** a user runs `taskless init --no-interactive` against a project where no command-receiving tool is detected (the install uses the `.agents/` fallback or only OpenCode/Codex) +- **THEN** the final line of output SHALL mention the Taskless skill and `taskless onboard` +- **AND** SHALL NOT mention `/tskl onboard` + +#### Scenario: Cancelled wizard suppresses the trailer + +- **WHEN** a user cancels the wizard (Ctrl-C or equivalent) +- **THEN** the trailer SHALL NOT be printed + +#### Scenario: Failed install suppresses the trailer + +- **WHEN** `taskless init` exits non-zero due to an install failure +- **THEN** the trailer SHALL NOT be printed + +#### Scenario: Trailer is printed regardless of onboarded state + +- **WHEN** a user re-runs `taskless init` and `install.onboarded` is already `true` +- **THEN** the trailer SHALL still be printed +- **AND** the trailer wording SHALL still adapt to whether commands were installed by the current run diff --git a/openspec/changes/archive/2026-05-15-add-onboard-command/specs/cli-onboard/spec.md b/openspec/changes/archive/2026-05-15-add-onboard-command/specs/cli-onboard/spec.md new file mode 100644 index 0000000..d84d4ec --- /dev/null +++ b/openspec/changes/archive/2026-05-15-add-onboard-command/specs/cli-onboard/spec.md @@ -0,0 +1,192 @@ +## ADDED Requirements + +### Requirement: Onboard subcommand exists with --force and --mark-complete flags + +The CLI SHALL support a `taskless onboard` subcommand. The subcommand SHALL accept the global `-d` working-directory flag, a `--force` boolean flag (default `false`), and a `--mark-complete` boolean flag (default `false`). `--force` and `--mark-complete` SHALL be mutually exclusive; supplying both SHALL exit with code 1 and a clear error message. The subcommand SHALL bootstrap the `.taskless/` directory via `ensureTasklessDirectory()` before reading or writing manifest state. + +#### Scenario: Onboard command is registered + +- **WHEN** a user runs `taskless --help` +- **THEN** `onboard` SHALL appear in the list of available subcommands +- **AND** the description SHALL describe it as the post-install discovery flow + +#### Scenario: Onboard accepts --force + +- **WHEN** a user runs `taskless onboard --force` +- **THEN** the command SHALL accept the flag without error + +#### Scenario: Onboard accepts --mark-complete + +- **WHEN** a user (or agent) runs `taskless onboard --mark-complete` +- **THEN** the command SHALL accept the flag without error + +#### Scenario: --force and --mark-complete are mutually exclusive + +- **WHEN** a user runs `taskless onboard --force --mark-complete` +- **THEN** the command SHALL exit with code 1 +- **AND** SHALL print an error message stating that the two flags cannot be combined + +#### Scenario: Onboard respects the global -d flag + +- **WHEN** a user runs `taskless onboard -d /path/to/repo` +- **THEN** all manifest reads and writes SHALL operate on `/path/to/repo/.taskless/taskless.json` + +### Requirement: Onboard gates on the onboarded manifest field + +When invoked without `--mark-complete`, the `taskless onboard` subcommand SHALL read `.taskless/taskless.json` and inspect the optional `install.onboarded` field. If the field equals `true` AND `--force` is not set, the subcommand SHALL print a short message stating the user is already onboarded and that `--force` re-runs the recipe, then exit with code 0 without printing the recipe. If the field is absent, `false`, or `--force` is set, the subcommand SHALL print the recipe content embedded from `packages/cli/src/help/onboard.txt` to stdout and exit with code 0. + +#### Scenario: Already onboarded without --force prints a short notice + +- **WHEN** `.taskless/taskless.json` contains `install.onboarded: true` +- **AND** a user runs `taskless onboard` (no `--force`) +- **THEN** the command SHALL print a short message indicating onboarding is already complete +- **AND** SHALL mention that `--force` re-runs the recipe +- **AND** SHALL exit with code 0 +- **AND** SHALL NOT print the recipe body + +#### Scenario: Already onboarded with --force prints the recipe + +- **WHEN** `.taskless/taskless.json` contains `install.onboarded: true` +- **AND** a user runs `taskless onboard --force` +- **THEN** the command SHALL print the recipe content from `onboard.txt` +- **AND** SHALL exit with code 0 + +#### Scenario: Onboarded field absent prints the recipe + +- **WHEN** `.taskless/taskless.json` does not contain an `install.onboarded` field +- **AND** a user runs `taskless onboard` +- **THEN** the command SHALL print the recipe content from `onboard.txt` +- **AND** SHALL exit with code 0 + +#### Scenario: Onboarded field is false prints the recipe + +- **WHEN** `.taskless/taskless.json` contains `install.onboarded: false` +- **AND** a user runs `taskless onboard` +- **THEN** the command SHALL print the recipe content from `onboard.txt` +- **AND** SHALL exit with code 0 + +### Requirement: --mark-complete writes onboarded:true to the manifest + +When invoked with `--mark-complete`, the `taskless onboard` subcommand SHALL write `install.onboarded: true` into `.taskless/taskless.json` and exit with code 0. The write SHALL be idempotent: invoking with `--mark-complete` when the field is already `true` SHALL succeed without error and without modifying other manifest fields. The write SHALL preserve all other manifest fields (including unknown fields and the existing `install` sub-object structure) on round-trip. The subcommand SHALL print a one-line confirmation to stdout indicating the manifest was updated. + +#### Scenario: Mark-complete writes the field on a fresh manifest + +- **WHEN** `.taskless/taskless.json` does not contain `install.onboarded` +- **AND** a user (or agent) runs `taskless onboard --mark-complete` +- **THEN** `install.onboarded` SHALL be `true` after the command completes +- **AND** the command SHALL print a confirmation +- **AND** SHALL exit with code 0 + +#### Scenario: Mark-complete is idempotent + +- **WHEN** `.taskless/taskless.json` already contains `install.onboarded: true` +- **AND** a user runs `taskless onboard --mark-complete` +- **THEN** the command SHALL succeed without error +- **AND** SHALL exit with code 0 +- **AND** SHALL NOT modify other manifest fields + +#### Scenario: Mark-complete preserves other install fields + +- **WHEN** `.taskless/taskless.json` contains an existing `install.targets` map with skills and commands +- **AND** a user runs `taskless onboard --mark-complete` +- **THEN** the existing `install.targets` map SHALL be preserved verbatim +- **AND** `install.onboarded` SHALL be added or set to `true` + +#### Scenario: Mark-complete preserves unknown top-level fields + +- **WHEN** `.taskless/taskless.json` contains an unknown top-level field (e.g., `experimental: {...}`) +- **AND** a user runs `taskless onboard --mark-complete` +- **THEN** the unknown field SHALL still be present after the write + +### Requirement: Onboard recipe is embedded from help/onboard.txt + +The CLI build SHALL embed `packages/cli/src/help/onboard.txt` into the bundle via the same `import.meta.glob` mechanism used for other help topics. The `taskless onboard` subcommand SHALL read the recipe content from the embedded bundle, not from the filesystem at runtime. The embedded recipe SHALL be the same content returned by `taskless help onboard`. + +#### Scenario: Recipe is available without filesystem access + +- **WHEN** a user runs `taskless onboard` via `npx @taskless/cli` +- **THEN** the recipe content SHALL be served from the embedded bundle +- **AND** SHALL NOT require any filesystem reads under `packages/cli/src/help/` + +#### Scenario: Onboard and help return the same recipe + +- **WHEN** a user runs `taskless onboard --force` (recipe path) +- **AND** a user runs `taskless help onboard` +- **THEN** the printed recipe content SHALL be identical between the two invocations + +### Requirement: Onboard recipe follows the canonical recipe template and is conversational + +The `onboard.txt` file SHALL follow the canonical recipe template defined in the `cli-help` capability (header with CLI version + topic version, `## Goal`, `## Preconditions`, `## Steps`, `## Errors`, `## See Also`). The `## Steps` section SHALL describe a conversational discovery flow rather than a fixed sequence. Specifically, the recipe SHALL instruct the agent to: + +1. Read `.taskless/taskless.json` and respect the `install.onboarded` field. +2. Open the conversation with a short menu of known sources for rule candidates: codebase TODOs/FIXMEs (via ripgrep or built-in search), agent-memory files (CLAUDE.md, AGENTS.md, .cursorrules, etc.), recent PR review comments (when `gh` is available), and issue-tracker tickets (when a relevant MCP is detected). +3. Encourage the user to suggest additional sources the agent may not know about. +4. Probe for tool availability before promising scans (e.g., check `command -v gh`, inspect available MCP tools). +5. For each chosen source, scan and filter for high-signal candidates: repeated patterns across multiple PRs/files/comments, comments that cite a doc or style guide, and merge-blocking review feedback. Filter out one-off nits and pure formatting feedback. +6. Synthesize a single bullet list where each bullet is a hypothetical rule expressed as `: `. +7. For each bullet, offer to materialize it via the existing `/tskl create rule` flow (link to the `rule create` topic via `npx @taskless/cli help rule create`). +8. At the end, ask the user whether they consider onboarding complete; on explicit yes, run `npx @taskless/cli onboard --mark-complete`. + +The recipe SHALL warn the agent against marking onboarding complete without explicit user confirmation. + +#### Scenario: Recipe header includes CLI and topic version + +- **WHEN** `onboard.txt` is read +- **THEN** the first line SHALL match the canonical header format `# Topic: onboard (CLI v / topic v)` + +#### Scenario: Recipe enumerates the known source menu + +- **WHEN** the recipe `## Steps` section is read +- **THEN** it SHALL list at least: codebase TODOs/FIXMEs, agent-memory files, PR review comments (with `gh`), and issue-tracker tickets (with MCP) + +#### Scenario: Recipe encourages user-suggested sources + +- **WHEN** the recipe `## Steps` section is read +- **THEN** it SHALL explicitly instruct the agent to ask the user whether other sources should be scanned + +#### Scenario: Recipe specifies the bullet output shape + +- **WHEN** the recipe `## Steps` section is read +- **THEN** it SHALL describe the rule-candidate output as a bullet list with `: ` per item + +#### Scenario: Recipe gates --mark-complete on user confirmation + +- **WHEN** the recipe `## Steps` section is read +- **THEN** it SHALL instruct the agent to ask for explicit user confirmation before invoking `npx @taskless/cli onboard --mark-complete` +- **AND** SHALL warn that the agent must NOT mark onboarding complete without that confirmation + +#### Scenario: Recipe references the rule create topic in See Also + +- **WHEN** the `## See Also` section is read +- **THEN** it SHALL include a reference to `taskless help rule create` + +### Requirement: Onboard emits intent telemetry + +The `taskless onboard` subcommand SHALL emit PostHog events on every invocation: + +- `cli_onboard_recipe` when the recipe is printed (either because the user is not yet onboarded or `--force` was used). +- `cli_onboard_already_done` when the gate refuses to print the recipe because `install.onboarded === true` and `--force` was not set. +- `cli_onboard_marked_complete` when `--mark-complete` succeeds. + +Events SHALL include a `forced` boolean property when relevant, and SHALL NOT include the contents of the manifest or any user-supplied content. + +#### Scenario: Recipe path emits cli_onboard_recipe + +- **WHEN** a user runs `taskless onboard` and the recipe is printed +- **THEN** PostHog SHALL receive a `cli_onboard_recipe` event +- **AND** the event SHALL include `forced: false` + +#### Scenario: Forced recipe path emits cli_onboard_recipe with forced=true + +- **WHEN** a user runs `taskless onboard --force` +- **THEN** PostHog SHALL receive a `cli_onboard_recipe` event with `forced: true` + +#### Scenario: Already-onboarded gate emits cli_onboard_already_done + +- **WHEN** a user runs `taskless onboard` and the gate refuses +- **THEN** PostHog SHALL receive a `cli_onboard_already_done` event + +#### Scenario: Mark-complete emits cli_onboard_marked_complete + +- **WHEN** `taskless onboard --mark-complete` succeeds +- **THEN** PostHog SHALL receive a `cli_onboard_marked_complete` event diff --git a/openspec/changes/archive/2026-05-15-add-onboard-command/specs/cli-taskless-bootstrap/spec.md b/openspec/changes/archive/2026-05-15-add-onboard-command/specs/cli-taskless-bootstrap/spec.md new file mode 100644 index 0000000..10f7c57 --- /dev/null +++ b/openspec/changes/archive/2026-05-15-add-onboard-command/specs/cli-taskless-bootstrap/spec.md @@ -0,0 +1,79 @@ +## MODIFIED Requirements + +### Requirement: Install manifest schema is a recognized top-level field + +The `TasklessManifest` type in `packages/cli/src/filesystem/migrate.ts` SHALL be extended to include an optional `install` field with the following shape: + +```ts +interface TasklessManifest { + version: number; + install?: { + installedAt?: string; + cliVersion?: string; + targets?: Record< + string, + { + skills?: string[]; + commands?: string[]; + } + >; + onboarded?: boolean; + }; +} +``` + +The `install.onboarded` field is optional and three-state: absent (never explicitly onboarded), `false` (explicitly reset), or `true` (user confirmed onboarding is complete). The `taskless init` install path SHALL NOT set this field. The field SHALL only be written by the `taskless onboard --mark-complete` subcommand, which is invoked by the host agent only after explicit user confirmation. Reads of `taskless.json` SHALL preserve unknown fields on round-trip writes so the manifest remains forward-compatible with future migrations. + +#### Scenario: Install field round-trips through read/write + +- **WHEN** `taskless.json` contains an `install` object +- **AND** the CLI reads the manifest and writes it back +- **THEN** the `install` object SHALL be preserved verbatim + +#### Scenario: Unknown fields are preserved on write + +- **WHEN** `taskless.json` contains an unknown top-level field (e.g., `experimental: {...}`) +- **AND** the CLI writes the manifest after a version bump +- **THEN** the unknown field SHALL still be present in the output + +#### Scenario: Onboarded field round-trips through read/write + +- **WHEN** `taskless.json` contains `install.onboarded: true` +- **AND** the CLI reads the manifest and writes it back (e.g., as part of a re-install) +- **THEN** the `install.onboarded: true` value SHALL be preserved + +#### Scenario: Init does not set onboarded + +- **WHEN** `taskless init` runs against a project with no prior `install.onboarded` value +- **THEN** the resulting `taskless.json` SHALL NOT contain an `install.onboarded` field + +## ADDED 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` | User confirmed onboarding is complete | + +The field SHALL only be written by the `taskless onboard --mark-complete` subcommand. No other CLI code path (including `taskless init`, the wizard, 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 diff --git a/openspec/changes/archive/2026-05-15-add-onboard-command/specs/skill-taskless/spec.md b/openspec/changes/archive/2026-05-15-add-onboard-command/specs/skill-taskless/spec.md new file mode 100644 index 0000000..f705921 --- /dev/null +++ b/openspec/changes/archive/2026-05-15-add-onboard-command/specs/skill-taskless/spec.md @@ -0,0 +1,80 @@ +## MODIFIED Requirements + +### Requirement: Skill description anchors triggers on Taskless-specific phrases + +The consolidated skill's `description` frontmatter field SHALL anchor triggers on: + +1. An explicit reference to "Taskless" in the user's message, OR +2. A reference to the `.taskless/` directory or files within it (rules, rule-tests, rule-metadata), OR +3. A request to add/write/create a rule where the user has NOT named a specific lint/format/static-analysis tool. The description SHALL include four illustrative example tools whose presence in the user's message suppresses this trigger: eslint, ruff, biome, ast-grep. The wording SHALL make clear the list is illustrative — any named lint/format/static-analysis tool suppresses the trigger. + +The description SHALL NOT contain a blanket "do NOT trigger on generic linting" instruction; that prior carve-out is replaced by the named-tool suppression in clause 3. + +#### Scenario: Description includes anchored trigger phrases + +- **WHEN** the skill `description` field is read +- **THEN** it SHALL include trigger phrases such as "create/add/write a taskless rule", "improve/fix/iterate on this taskless rule", "run taskless", "taskless login", "add taskless to CI" +- **AND** SHALL include the unspecified-tool clause covering "add/write/create a rule" with no tool named +- **AND** SHALL list at least the four illustrative suppressing tool names: eslint, ruff, biome, ast-grep + +#### Scenario: Description omits the prior blanket carve-out + +- **WHEN** the skill `description` field is read +- **THEN** it SHALL NOT contain wording instructing the agent to never trigger on generic ESLint/linting requests +- **AND** any suppression wording SHALL be expressed via the named-tool clause + +#### Scenario: Description is at most 1024 characters + +- **WHEN** the skill `description` field length is measured +- **THEN** it SHALL be at most 1024 characters (Agent Skills spec limit) + +### Requirement: Skill body is a router, not an inline recipe + +The consolidated skill body SHALL NOT contain step-by-step instructions for any individual Taskless task. The body SHALL be a router that: + +1. States explicitly that the agent does NOT have the steps for any Taskless action in its context. +2. Instructs the agent to fetch the canonical recipe via `npx @taskless/cli help ` before proceeding. +3. Provides a topic disambiguation table mapping user intents to topic names. The table SHALL include a row for the new `onboard` topic. +4. Includes a `## --anonymous` section explaining the global flag's behavior. +5. Includes a first-step `.taskless/` presence check with graceful failure ("ask the user to confirm they meant Taskless"). +6. Includes a `## Quiet suggestion` (or equivalently named) section governing the proactive trigger introduced via the description's named-tool clause. This section SHALL specify that: + - When the skill triggers because the user wants to add a rule and has not named a specific tool, the agent SHALL surface a single-line offer to capture the rule via Taskless rather than launching into a full recipe (e.g., "I can capture this as a Taskless rule if you want — say so, or I'll proceed with X"). + - If the user declines or ignores the offer, the agent SHALL proceed with whatever it would have done without the skill. + - If the user declines, the agent SHALL NOT re-offer Taskless in the same conversation. No persistent decline state SHALL be written to disk. + - If the user accepts, the skill router SHALL proceed normally to fetch `npx @taskless/cli help rule create`. + +The body SHALL be no more than 80 lines of markdown to keep the always-loaded surface small. (The previous 60-line cap is relaxed to accommodate the new quiet-suggestion section and the `onboard` row.) + +#### Scenario: Skill body warns against improvising + +- **WHEN** the skill body is read by an agent +- **THEN** it SHALL contain explicit framing such as "You do NOT have the steps... do not improvise from prior knowledge" + +#### Scenario: Skill body lists available topics including onboard + +- **WHEN** the skill body is read by an agent +- **THEN** it SHALL include a table or list mapping user intents to the corresponding `tskl help ` invocations +- **AND** the table SHALL include a row for `onboard` mapped to `npx @taskless/cli help onboard` (or equivalent invocation of the onboard topic) + +#### Scenario: Skill body checks for .taskless directory + +- **WHEN** the skill is invoked +- **THEN** the body's first step SHALL instruct the agent to check whether `.taskless/` exists in the working directory +- **AND** to ask the user to confirm Taskless is what they meant if the directory is absent + +#### Scenario: Skill body specifies quiet suggestion behavior + +- **WHEN** the skill is triggered by the unspecified-tool clause from the description +- **THEN** the body's quiet-suggestion section SHALL instruct the agent to surface a single-line offer rather than a full recipe +- **AND** SHALL instruct the agent NOT to re-offer in the same conversation if declined +- **AND** SHALL specify that no persistent decline state is written + +#### Scenario: Skill body specifies in-conversation decline is sticky + +- **WHEN** the user has declined a quiet-suggestion offer once in the current conversation +- **THEN** the body SHALL instruct the agent not to surface the offer again in the same conversation + +#### Scenario: Skill body length cap + +- **WHEN** the skill body is measured +- **THEN** it SHALL be no more than 80 lines of markdown diff --git a/openspec/changes/archive/2026-05-15-add-onboard-command/tasks.md b/openspec/changes/archive/2026-05-15-add-onboard-command/tasks.md new file mode 100644 index 0000000..c2ce350 --- /dev/null +++ b/openspec/changes/archive/2026-05-15-add-onboard-command/tasks.md @@ -0,0 +1,69 @@ +## 1. Manifest schema + +- [x] 1.1 Extend `TasklessManifest['install']` in `packages/cli/src/filesystem/migrate.ts` with optional `onboarded?: boolean` field +- [x] 1.2 Verify round-trip read/write preserves the `onboarded` field (no migration needed; field is optional) +- [x] 1.3 Confirm `taskless init` does not write the field (no code change expected; add a unit test if a relevant test surface already exists) + +## 2. Onboard recipe content + +- [x] 2.1 Create `packages/cli/src/help/onboard.txt` following the canonical recipe template (`# Topic: onboard (CLI v{{CLI_VERSION}} / topic v1)`, `## Goal`, `## Preconditions`, `## Steps`, `## Errors`, `## See Also`) +- [x] 2.2 In `## Steps`, document the conversational flow: read manifest, present source menu (TODOs/FIXMEs, agent-memory files, PR comments via `gh`, issue tracker via MCP), invite user-suggested sources, probe tool availability, scan with high-signal filtering, output bullet list of `: `, offer `/tskl create rule` per item, ask for confirmation before `--mark-complete` +- [x] 2.3 Reference `taskless help rule create` in `## See Also` +- [x] 2.4 Confirm `import.meta.glob` picks up `onboard.txt` automatically (no glob pattern change expected; verify by inspecting the embedded map at runtime) + +## 3. CLI subcommand + +- [x] 3.1 Create `packages/cli/src/commands/onboard.ts` exporting an `onboardCommand` defined with `citty.defineCommand` +- [x] 3.2 Define args: `dir` (`-d`, string), `force` (boolean, default false), `mark-complete` (boolean, default false) +- [x] 3.3 Reject the combination `--force --mark-complete` with exit code 1 and a clear error message +- [x] 3.4 In default mode: bootstrap `.taskless/` via `ensureTasklessDirectory()`, read manifest, gate on `install.onboarded === true && !force`, print recipe from embedded `onboard.txt` +- [x] 3.5 In `--mark-complete` mode: bootstrap `.taskless/`, read manifest, set `install.onboarded = true`, write manifest preserving all other fields, print confirmation +- [x] 3.6 Wire `onboardCommand` into the root command in `packages/cli/src/index.ts` +- [x] 3.7 Emit telemetry events: `cli_onboard_recipe` (with `forced` property), `cli_onboard_already_done`, `cli_onboard_marked_complete` + +## 3a. Recipe templating refactor (sprintf-js) + +- [x] 3a.1 Add `sprintf-js` (and `@types/sprintf-js`) to `packages/cli` dependencies +- [x] 3a.2 Refactor `renderRecipe` in `packages/cli/src/commands/help.ts` to use `sprintf-js` named arguments. Provide `CLI_VERSION` always, `INPUT_SCHEMA` when the recipe contains the placeholder, and `PACKAGE_MANAGER_DLX` always (rendered as `` agent-fill marker) +- [x] 3a.3 Migrate every recipe under `packages/cli/src/help/*.txt` from `{{KEY}}` mustache syntax to `%(KEY)s` sprintf-js named-argument syntax +- [x] 3a.4 Smoke-test render output of a representative recipe (e.g. `taskless help ci`) to confirm `%(PACKAGE_MANAGER_DLX)s` resolves to `` and `%(CLI_VERSION)s` resolves to the build-time version + +## 4. Help index registration + +- [x] 4.1 Confirm `taskless help onboard` resolves and prints `onboard.txt` (no code change expected if the help command uses a glob — verify behavior) +- [x] 4.2 Update the `taskless help` (no-args) topic table in `packages/cli/src/commands/help.ts` (or wherever the index table is built) to include the `onboard` row with a one-line summary +- [x] 4.3 Confirm `help_onboard` PostHog event is emitted for `taskless help onboard` (no code change expected; verify the existing intent-telemetry generates it) + +## 5. Init trailer + +- [x] 5.1 Add a one-line onboarding trailer to the wizard's success path (after the install summary, before exit) in `packages/cli/src/wizard/` +- [x] 5.2 Add the same trailer to the `--no-interactive` success path in `packages/cli/src/commands/init.ts` +- [x] 5.3 Suppress the trailer when init exits non-zero or the install was a no-op +- [x] 5.4 Verify the trailer is printed regardless of the value of `install.onboarded` +- [x] 5.5 Branch the trailer wording on whether the install plan included commands: `/tskl onboard` form when at least one target received commands (Claude Code or Cursor); skill-via-natural-language form when no target received commands (OpenCode, Codex, `.agents/` fallback). Thread the flag through `runNonInteractive`'s return type and via `planTargets` in the wizard. + +## 6. Skill description and body + +- [x] 6.1 Update `skills/taskless/SKILL.md` `description` frontmatter to include the unspecified-tool clause with the four illustrative examples (eslint, ruff, biome, ast-grep) and remove the prior blanket "do NOT trigger on generic ESLint/linting" carve-out +- [x] 6.2 Verify the description fits within 1024 characters +- [x] 6.3 Add an `onboard` row to the skill body's topic disambiguation table mapped to `npx @taskless/cli help onboard` +- [x] 6.4 Add a `## Quiet suggestion` section to the skill body specifying single-line offer wording, in-conversation sticky decline, and no persistent decline state +- [x] 6.5 Verify the skill body is no more than 80 lines of markdown + +## 7. Tests + +- [x] 7.1 Unit test: `taskless onboard` with no manifest bootstraps `.taskless/` and prints the recipe +- [x] 7.2 Unit test: `taskless onboard` with `install.onboarded: true` prints the gate notice and exits 0 without printing recipe +- [x] 7.3 Unit test: `taskless onboard --force` with `install.onboarded: true` prints the recipe and exits 0 +- [x] 7.4 Unit test: `taskless onboard --mark-complete` writes `install.onboarded: true` and preserves other manifest fields (including unknown top-level fields) +- [x] 7.5 Unit test: `taskless onboard --mark-complete` is idempotent (running twice leaves the file in the same state) +- [x] 7.6 Unit test: `taskless onboard --force --mark-complete` exits 1 with an error message +- [x] 7.7 Unit test: `taskless help onboard` returns the same content as `taskless onboard --force` (recipe path) +- [x] 7.8 Integration test or snapshot: init success paths include the onboarding trailer; cancelled and failed paths do not +- [x] 7.9 Update existing help-extensions tests to assert the new `%(KEY)s` sprintf-js syntax (and add a `%(PACKAGE_MANAGER_DLX)s` → `` rendering test) + +## 8. Documentation and release prep + +- [x] 8.1 Add a CHANGELOG entry under the appropriate next-version heading describing the new `onboard` command, the `onboarded` manifest field, the init trailer, and the skill trigger expansion (added as a changeset at `.changeset/onboard-command.md`; release tooling rolls it into the next `@taskless/cli` minor) +- [x] 8.2 Update `packages/cli/README.md` (if it documents subcommands) to include `taskless onboard` +- [x] 8.3 Run `pnpm typecheck` and `pnpm lint` and resolve any issues diff --git a/openspec/specs/cli-help/spec.md b/openspec/specs/cli-help/spec.md index a4a3924..f9ba03b 100644 --- a/openspec/specs/cli-help/spec.md +++ b/openspec/specs/cli-help/spec.md @@ -67,28 +67,117 @@ Help text files SHALL be located at `packages/cli/src/help/` as plain `.txt` fil ### Requirement: Help text files follow a consistent format -Every help text file at `packages/cli/src/help/.txt` SHALL follow the canonical recipe template: +Every help text file at `packages/cli/src/help/.txt` SHALL follow the canonical recipe template: a single-line header `# Topic: (CLI v%(CLI_VERSION)s / topic v)`, followed by `## Goal`, `## Preconditions`, `## Steps`, optional `## Input schema` (for recipes that take `--from`), `## Errors`, and `## See Also` sections in that order. Recipe templates SHALL use sprintf-js `%(KEY)s` named-argument placeholders for all substitution. The header SHALL embed `%(CLI_VERSION)s` for the CLI version. Topics that document a `--from` input SHALL embed `%(INPUT_SCHEMA)s` inside the `## Input schema` fenced code block. The topic version integer in the header SHALL be a literal value maintained by the recipe author and bumped when the recipe changes meaningfully. -``` -# Topic: (CLI v / topic v) +#### Scenario: Recipe contains all template sections + +- **WHEN** any `.txt` file is read +- **THEN** it SHALL begin with a `# Topic:` header containing `%(CLI_VERSION)s` and the topic version integer +- **AND** SHALL contain `## Goal`, `## Preconditions`, `## Steps`, `## Errors`, and `## See Also` sections in that order + +#### Scenario: Recipe with --from input includes JSON schema placeholder + +- **WHEN** a topic recipe documents a CLI invocation that uses `--from ` +- **THEN** the recipe SHALL contain an `## Input schema` section with a code-fenced block containing the `%(INPUT_SCHEMA)s` placeholder +- **AND** the JSON Schema SHALL be derived at render time from the corresponding Zod schema in `packages/cli/src/schemas/` + +#### Scenario: Header version reflects build-time CLI version + +- **WHEN** the CLI bundle is built +- **THEN** the recipe header's `%(CLI_VERSION)s` placeholder SHALL be substituted at render time from `packages/cli/package.json` +- **AND** SHALL match the version reported by `taskless info` + +### Requirement: Recipe substitution uses sprintf-js named arguments + +Recipe rendering SHALL substitute placeholders via `sprintf-js` using its named-argument form (`%(KEY)s`). The renderer SHALL build a variables table for each render call containing two flavors of substitution: + +1. **System-resolved values** — keys whose values come from runtime state. The renderer SHALL provide `CLI_VERSION` (resolved from the build-time version constant) for every render. The renderer SHALL provide `INPUT_SCHEMA` only when the recipe content contains the `%(INPUT_SCHEMA)s` placeholder; the value is the JSON Schema rendered from the topic's Zod schema in `packages/cli/src/schemas/`, or the literal string `"(no input schema for this topic)"` when no Zod schema is registered for the topic. +2. **Agent-fill markers** — keys whose values render as a lowercase angle-bracket token of the same name (e.g. `PACKAGE_MANAGER_DLX` renders as ``). The renderer SHALL provide `PACKAGE_MANAGER_DLX` for every render. Agent-fill markers exist so the consuming agent can substitute the value at execution time without the recipe having to invent a per-recipe placeholder convention. + +Recipe authors SHALL escape any literal `%` character in recipe content as `%%` per sprintf-js conventions. The renderer SHALL NOT introduce any other placeholder syntax (`{{KEY}}`, `${KEY}`, etc.); all substitution SHALL flow through the sprintf-js named-argument table. + +#### Scenario: CLI_VERSION substitutes the build-time version + +- **WHEN** any recipe is rendered +- **THEN** every `%(CLI_VERSION)s` occurrence SHALL be replaced with the build-time CLI version + +#### Scenario: INPUT_SCHEMA substitutes only when present in the recipe + +- **WHEN** a recipe contains `%(INPUT_SCHEMA)s` +- **THEN** it SHALL be replaced with the JSON Schema rendered from the topic's Zod schema +- **AND** when no Zod schema is registered for the topic, the placeholder SHALL render as `(no input schema for this topic)` + +#### Scenario: PACKAGE_MANAGER_DLX renders as an agent-fill marker + +- **WHEN** any recipe contains `%(PACKAGE_MANAGER_DLX)s` +- **THEN** the rendered output SHALL contain the literal token `` at every occurrence + +#### Scenario: No legacy placeholder syntax remains in recipes + +- **WHEN** any `.txt` file under `packages/cli/src/help/` is read +- **THEN** it SHALL NOT contain a `{{KEY}}` mustache-style placeholder +- **AND** all substitution SHALL be expressed as `%(KEY)s` sprintf-js named arguments + +### Requirement: onboard topic is registered in the help index + +A new help topic `onboard` SHALL be registered. The CLI SHALL embed `packages/cli/src/help/onboard.txt` at build time via the existing `import.meta.glob` mechanism. `taskless help onboard` SHALL print the contents of `onboard.txt`. The topic SHALL appear in the output of `taskless help` (the index) with a one-line summary describing it as the post-install rule-discovery flow. + +#### Scenario: Help for onboard returns the recipe + +- **WHEN** a user runs `taskless help onboard` +- **THEN** the CLI SHALL print the contents of `onboard.txt` to stdout +- **AND** SHALL exit with code 0 + +#### Scenario: Help index includes onboard + +- **WHEN** a user runs `taskless help` (no args) +- **THEN** the topic table SHALL include a row for `onboard` +- **AND** the row SHALL describe it as the post-install rule-discovery flow + +#### Scenario: Onboard recipe is embedded at build time + +- **WHEN** the CLI bundle is built +- **THEN** `import.meta.glob` matching the help directory SHALL include `onboard.txt` +- **AND** the recipe SHALL be available at runtime without filesystem access + +#### Scenario: Help onboard with --anonymous falls back + +- **WHEN** a user runs `taskless help onboard --anonymous` +- **AND** no `onboard.anonymous.txt` exists +- **THEN** the CLI SHALL print the contents of `onboard.txt` (anonymous is a no-op for this topic) + +### Requirement: help_onboard intent telemetry + +The help command's existing intent-telemetry requirement SHALL extend naturally to the new topic: invocations of `taskless help onboard` SHALL emit a `help_onboard` PostHog event, consistent with the `help_` pattern. + +#### Scenario: Help onboard emits help_onboard + +- **WHEN** an agent runs `taskless help onboard` +- **THEN** PostHog SHALL receive a `help_onboard` event ## Goal + ## Preconditions + ## Steps + ## Input schema + +generated from the corresponding Zod schema via zod-to-json-schema> ## Errors + ## See Also + ``` diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index f22eafb..7e4289a 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -483,3 +483,47 @@ Re-install diff computation is unchanged. With v0.7.0 the diff for a v0.6 user s - **WHEN** a user with v0.6 installed runs `taskless init` after upgrading to v0.7.0 - **THEN** the wizard summary SHALL list the 10 obsolete skills and 6 obsolete commands as removals - **AND** SHALL require user confirmation before deleting + +### Requirement: Init prints a post-install onboarding trailer + +After a successful install (both wizard and `--no-interactive` paths), `taskless init` SHALL print a single one-line trailer pointing the user at the new onboarding flow. The trailer SHALL be printed AFTER the install summary (the lines that report what was written and any obsolete files removed) and SHALL be the final line of output before the process exits. The trailer SHALL NOT be gated on the value of `install.onboarded` in the manifest — it is informational, printed every successful install. The trailer's wording SHALL adapt to the install plan: when at least one installed target received the `tskl` slash command (Claude Code or Cursor), the trailer SHALL mention `/tskl onboard`, the Taskless skill, AND `taskless onboard` (since command-receiving tools also get the skill, both AI-tool entry points are surfaced); when no installed target received commands (OpenCode, Codex, or the `.agents/` fallback), the trailer SHALL mention only the Taskless skill and `taskless onboard`, and SHALL NOT mention `/tskl onboard`. The trailer SHALL be suppressed when `taskless init` exits non-zero (cancelled wizard, install failure, etc.) or when the install was a no-op (no targets selected, no files to write). + +#### Scenario: Wizard install with commands mentions slash command, skill, and CLI + +- **WHEN** a user runs `taskless init` in an interactive terminal and the wizard completes successfully +- **AND** at least one selected target received the `tskl` slash command (Claude Code or Cursor) +- **THEN** the final line of output SHALL be a single-line trailer that mentions `/tskl onboard`, the Taskless skill, AND `taskless onboard` + +#### Scenario: Wizard install without commands mentions skill and CLI only + +- **WHEN** a user runs `taskless init` in an interactive terminal and the wizard completes successfully +- **AND** no selected target received commands (e.g. only OpenCode and/or Codex were chosen) +- **THEN** the final line of output SHALL be a single-line trailer instructing the user to invoke the Taskless skill via natural language and mentioning `taskless onboard` as a terminal fallback +- **AND** the trailer SHALL NOT mention `/tskl onboard` + +#### Scenario: Non-interactive install with commands mentions slash command, skill, and CLI + +- **WHEN** a user runs `taskless init --no-interactive` against a project where Claude Code or Cursor is detected +- **THEN** the final line of output SHALL mention `/tskl onboard`, the Taskless skill, AND `taskless onboard` + +#### Scenario: Non-interactive install with no commands mentions skill and CLI only + +- **WHEN** a user runs `taskless init --no-interactive` against a project where no command-receiving tool is detected (the install uses the `.agents/` fallback or only OpenCode/Codex) +- **THEN** the final line of output SHALL mention the Taskless skill and `taskless onboard` +- **AND** SHALL NOT mention `/tskl onboard` + +#### Scenario: Cancelled wizard suppresses the trailer + +- **WHEN** a user cancels the wizard (Ctrl-C or equivalent) +- **THEN** the trailer SHALL NOT be printed + +#### Scenario: Failed install suppresses the trailer + +- **WHEN** `taskless init` exits non-zero due to an install failure +- **THEN** the trailer SHALL NOT be printed + +#### Scenario: Trailer is printed regardless of onboarded state + +- **WHEN** a user re-runs `taskless init` and `install.onboarded` is already `true` +- **THEN** the trailer SHALL still be printed +- **AND** the trailer wording SHALL still adapt to whether commands were installed by the current run diff --git a/openspec/specs/cli-onboard/spec.md b/openspec/specs/cli-onboard/spec.md new file mode 100644 index 0000000..bfefd49 --- /dev/null +++ b/openspec/specs/cli-onboard/spec.md @@ -0,0 +1,205 @@ +# cli-onboard Specification + +## Purpose + +Defines the `taskless onboard` subcommand: the post-install discovery flow that helps a fresh user go from zero rules to a useful starter set. Unlike `taskless init` (which is fast, deterministic, and gets the user _installed_), `onboard` delivers a conversational agent recipe that mines the codebase, agent-memory files, and (when available) PR review comments and issue tracker tickets for high-signal rule candidates, then surfaces them as a bullet list the user materializes via `taskless rule create`. + +This capability covers: + +- **Subcommand surface**: three modes via flag combinations — default prints the recipe (refused when already complete), `--force` re-runs regardless of state, `--mark-complete` writes `install.onboarded: true`. `--force` and `--mark-complete` are mutually exclusive. +- **Manifest gating and writes**: the optional 3-state `install.onboarded` field on `.taskless/taskless.json` (absent / `false` / `true`) is the single source of truth for whether onboarding is complete. Only this subcommand writes the field, and only via `--mark-complete`. The agent invokes `--mark-complete` only after explicit user confirmation per the recipe. +- **Recipe embedding**: the recipe lives at `packages/cli/src/help/onboard.txt`, embedded into the CLI bundle at build time and rendered via the same sprintf-js path the help command uses (`taskless help onboard` returns the same content as `taskless onboard --force`). +- **Telemetry**: emits `cli_onboard_recipe` (with a `forced` property), `cli_onboard_already_done`, and `cli_onboard_marked_complete` PostHog events on the appropriate code paths. + +## Requirements + +### Requirement: Onboard subcommand exists with --force and --mark-complete flags + +The CLI SHALL support a `taskless onboard` subcommand. The subcommand SHALL accept the global `-d` working-directory flag, a `--force` boolean flag (default `false`), and a `--mark-complete` boolean flag (default `false`). `--force` and `--mark-complete` SHALL be mutually exclusive; supplying both SHALL exit with code 1 and a clear error message. The subcommand SHALL bootstrap the `.taskless/` directory via `ensureTasklessDirectory()` before reading or writing manifest state. + +#### Scenario: Onboard command is registered + +- **WHEN** a user runs `taskless --help` +- **THEN** `onboard` SHALL appear in the list of available subcommands +- **AND** the description SHALL describe it as the post-install discovery flow + +#### Scenario: Onboard accepts --force + +- **WHEN** a user runs `taskless onboard --force` +- **THEN** the command SHALL accept the flag without error + +#### Scenario: Onboard accepts --mark-complete + +- **WHEN** a user (or agent) runs `taskless onboard --mark-complete` +- **THEN** the command SHALL accept the flag without error + +#### Scenario: --force and --mark-complete are mutually exclusive + +- **WHEN** a user runs `taskless onboard --force --mark-complete` +- **THEN** the command SHALL exit with code 1 +- **AND** SHALL print an error message stating that the two flags cannot be combined + +#### Scenario: Onboard respects the global -d flag + +- **WHEN** a user runs `taskless onboard -d /path/to/repo` +- **THEN** all manifest reads and writes SHALL operate on `/path/to/repo/.taskless/taskless.json` + +### Requirement: Onboard gates on the onboarded manifest field + +When invoked without `--mark-complete`, the `taskless onboard` subcommand SHALL read `.taskless/taskless.json` and inspect the optional `install.onboarded` field. If the field equals `true` AND `--force` is not set, the subcommand SHALL print a short message stating the user is already onboarded and that `--force` re-runs the recipe, then exit with code 0 without printing the recipe. If the field is absent, `false`, or `--force` is set, the subcommand SHALL print the recipe content embedded from `packages/cli/src/help/onboard.txt` to stdout and exit with code 0. + +#### Scenario: Already onboarded without --force prints a short notice + +- **WHEN** `.taskless/taskless.json` contains `install.onboarded: true` +- **AND** a user runs `taskless onboard` (no `--force`) +- **THEN** the command SHALL print a short message indicating onboarding is already complete +- **AND** SHALL mention that `--force` re-runs the recipe +- **AND** SHALL exit with code 0 +- **AND** SHALL NOT print the recipe body + +#### Scenario: Already onboarded with --force prints the recipe + +- **WHEN** `.taskless/taskless.json` contains `install.onboarded: true` +- **AND** a user runs `taskless onboard --force` +- **THEN** the command SHALL print the recipe content from `onboard.txt` +- **AND** SHALL exit with code 0 + +#### Scenario: Onboarded field absent prints the recipe + +- **WHEN** `.taskless/taskless.json` does not contain an `install.onboarded` field +- **AND** a user runs `taskless onboard` +- **THEN** the command SHALL print the recipe content from `onboard.txt` +- **AND** SHALL exit with code 0 + +#### Scenario: Onboarded field is false prints the recipe + +- **WHEN** `.taskless/taskless.json` contains `install.onboarded: false` +- **AND** a user runs `taskless onboard` +- **THEN** the command SHALL print the recipe content from `onboard.txt` +- **AND** SHALL exit with code 0 + +### Requirement: --mark-complete writes onboarded:true to the manifest + +When invoked with `--mark-complete`, the `taskless onboard` subcommand SHALL write `install.onboarded: true` into `.taskless/taskless.json` and exit with code 0. The write SHALL be idempotent: invoking with `--mark-complete` when the field is already `true` SHALL succeed without error and without modifying other manifest fields. The write SHALL preserve all other manifest fields (including unknown fields and the existing `install` sub-object structure) on round-trip. The subcommand SHALL print a one-line confirmation to stdout indicating the manifest was updated. + +#### Scenario: Mark-complete writes the field on a fresh manifest + +- **WHEN** `.taskless/taskless.json` does not contain `install.onboarded` +- **AND** a user (or agent) runs `taskless onboard --mark-complete` +- **THEN** `install.onboarded` SHALL be `true` after the command completes +- **AND** the command SHALL print a confirmation +- **AND** SHALL exit with code 0 + +#### Scenario: Mark-complete is idempotent + +- **WHEN** `.taskless/taskless.json` already contains `install.onboarded: true` +- **AND** a user runs `taskless onboard --mark-complete` +- **THEN** the command SHALL succeed without error +- **AND** SHALL exit with code 0 +- **AND** SHALL NOT modify other manifest fields + +#### Scenario: Mark-complete preserves other install fields + +- **WHEN** `.taskless/taskless.json` contains an existing `install.targets` map with skills and commands +- **AND** a user runs `taskless onboard --mark-complete` +- **THEN** the existing `install.targets` map SHALL be preserved verbatim +- **AND** `install.onboarded` SHALL be added or set to `true` + +#### Scenario: Mark-complete preserves unknown top-level fields + +- **WHEN** `.taskless/taskless.json` contains an unknown top-level field (e.g., `experimental: {...}`) +- **AND** a user runs `taskless onboard --mark-complete` +- **THEN** the unknown field SHALL still be present after the write + +### Requirement: Onboard recipe is embedded from help/onboard.txt + +The CLI build SHALL embed `packages/cli/src/help/onboard.txt` into the bundle via the same `import.meta.glob` mechanism used for other help topics. The `taskless onboard` subcommand SHALL read the recipe content from the embedded bundle, not from the filesystem at runtime. The embedded recipe SHALL be the same content returned by `taskless help onboard`. + +#### Scenario: Recipe is available without filesystem access + +- **WHEN** a user runs `taskless onboard` via `npx @taskless/cli` +- **THEN** the recipe content SHALL be served from the embedded bundle +- **AND** SHALL NOT require any filesystem reads under `packages/cli/src/help/` + +#### Scenario: Onboard and help return the same recipe + +- **WHEN** a user runs `taskless onboard --force` (recipe path) +- **AND** a user runs `taskless help onboard` +- **THEN** the printed recipe content SHALL be identical between the two invocations + +### Requirement: Onboard recipe follows the canonical recipe template and is conversational + +The `onboard.txt` file SHALL follow the canonical recipe template defined in the `cli-help` capability (header with CLI version + topic version, `## Goal`, `## Preconditions`, `## Steps`, `## Errors`, `## See Also`). The `## Steps` section SHALL describe a conversational discovery flow rather than a fixed sequence. Specifically, the recipe SHALL instruct the agent to: + +1. Read `.taskless/taskless.json` and respect the `install.onboarded` field. +2. Open the conversation with a short menu of known sources for rule candidates: codebase TODOs/FIXMEs (via ripgrep or built-in search), agent-memory files (CLAUDE.md, AGENTS.md, .cursorrules, etc.), recent PR review comments (when `gh` is available), and issue-tracker tickets (when a relevant MCP is detected). +3. Encourage the user to suggest additional sources the agent may not know about. +4. Probe for tool availability before promising scans (e.g., check `command -v gh`, inspect available MCP tools). +5. For each chosen source, scan and filter for high-signal candidates: repeated patterns across multiple PRs/files/comments, comments that cite a doc or style guide, and merge-blocking review feedback. Filter out one-off nits and pure formatting feedback. +6. Synthesize a single bullet list where each bullet is a hypothetical rule expressed as `: `. +7. For each bullet, offer to materialize it via the existing `/tskl create rule` flow (link to the `rule create` topic via `npx @taskless/cli help rule create`). +8. At the end, ask the user whether they consider onboarding complete; on explicit yes, run `npx @taskless/cli onboard --mark-complete`. + +The recipe SHALL warn the agent against marking onboarding complete without explicit user confirmation. + +#### Scenario: Recipe header includes CLI and topic version + +- **WHEN** `onboard.txt` is read +- **THEN** the first line SHALL match the canonical header format `# Topic: onboard (CLI v / topic v)` + +#### Scenario: Recipe enumerates the known source menu + +- **WHEN** the recipe `## Steps` section is read +- **THEN** it SHALL list at least: codebase TODOs/FIXMEs, agent-memory files, PR review comments (with `gh`), and issue-tracker tickets (with MCP) + +#### Scenario: Recipe encourages user-suggested sources + +- **WHEN** the recipe `## Steps` section is read +- **THEN** it SHALL explicitly instruct the agent to ask the user whether other sources should be scanned + +#### Scenario: Recipe specifies the bullet output shape + +- **WHEN** the recipe `## Steps` section is read +- **THEN** it SHALL describe the rule-candidate output as a bullet list with `: ` per item + +#### Scenario: Recipe gates --mark-complete on user confirmation + +- **WHEN** the recipe `## Steps` section is read +- **THEN** it SHALL instruct the agent to ask for explicit user confirmation before invoking `npx @taskless/cli onboard --mark-complete` +- **AND** SHALL warn that the agent must NOT mark onboarding complete without that confirmation + +#### Scenario: Recipe references the rule create topic in See Also + +- **WHEN** the `## See Also` section is read +- **THEN** it SHALL include a reference to `taskless help rule create` + +### Requirement: Onboard emits intent telemetry + +The `taskless onboard` subcommand SHALL emit PostHog events on every invocation: + +- `cli_onboard_recipe` when the recipe is printed (either because the user is not yet onboarded or `--force` was used). +- `cli_onboard_already_done` when the gate refuses to print the recipe because `install.onboarded === true` and `--force` was not set. +- `cli_onboard_marked_complete` when `--mark-complete` succeeds. + +Events SHALL include a `forced` boolean property when relevant, and SHALL NOT include the contents of the manifest or any user-supplied content. + +#### Scenario: Recipe path emits cli_onboard_recipe + +- **WHEN** a user runs `taskless onboard` and the recipe is printed +- **THEN** PostHog SHALL receive a `cli_onboard_recipe` event +- **AND** the event SHALL include `forced: false` + +#### Scenario: Forced recipe path emits cli_onboard_recipe with forced=true + +- **WHEN** a user runs `taskless onboard --force` +- **THEN** PostHog SHALL receive a `cli_onboard_recipe` event with `forced: true` + +#### Scenario: Already-onboarded gate emits cli_onboard_already_done + +- **WHEN** a user runs `taskless onboard` and the gate refuses +- **THEN** PostHog SHALL receive a `cli_onboard_already_done` event + +#### Scenario: Mark-complete emits cli_onboard_marked_complete + +- **WHEN** `taskless onboard --mark-complete` succeeds +- **THEN** PostHog SHALL receive a `cli_onboard_marked_complete` event diff --git a/openspec/specs/cli-taskless-bootstrap/spec.md b/openspec/specs/cli-taskless-bootstrap/spec.md index 40306c4..bf56420 100644 --- a/openspec/specs/cli-taskless-bootstrap/spec.md +++ b/openspec/specs/cli-taskless-bootstrap/spec.md @@ -132,11 +132,12 @@ interface TasklessManifest { commands?: string[]; } >; + onboarded?: boolean; }; } ``` -Reads of `taskless.json` SHALL preserve unknown fields on round-trip writes so the manifest remains forward-compatible with future migrations. +The `install.onboarded` field is optional and three-state: absent (never explicitly onboarded), `false` (explicitly reset), or `true` (user confirmed onboarding is complete). The `taskless init` install path SHALL NOT set this field. The field SHALL only be written by the `taskless onboard --mark-complete` subcommand, which is invoked by the host agent only after explicit user confirmation. Reads of `taskless.json` SHALL preserve unknown fields on round-trip writes so the manifest remains forward-compatible with future migrations. #### Scenario: Install field round-trips through read/write @@ -149,3 +150,43 @@ Reads of `taskless.json` SHALL preserve unknown fields on round-trip writes so t - **WHEN** `taskless.json` contains an unknown top-level field (e.g., `experimental: {...}`) - **AND** the CLI writes the manifest after a version bump - **THEN** the unknown field SHALL still be present in the output + +#### Scenario: Onboarded field round-trips through read/write + +- **WHEN** `taskless.json` contains `install.onboarded: true` +- **AND** the CLI reads the manifest and writes it back (e.g., as part of a re-install) +- **THEN** the `install.onboarded: true` value SHALL be preserved + +#### Scenario: Init does not set onboarded + +- **WHEN** `taskless init` runs against a project with no prior `install.onboarded` value +- **THEN** the resulting `taskless.json` SHALL NOT contain an `install.onboarded` field + +### 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` | User confirmed onboarding is complete | + +The field SHALL only be written by the `taskless onboard --mark-complete` subcommand. No other CLI code path (including `taskless init`, the wizard, 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 diff --git a/openspec/specs/skill-taskless/spec.md b/openspec/specs/skill-taskless/spec.md index 4aad4db..09d776d 100644 --- a/openspec/specs/skill-taskless/spec.md +++ b/openspec/specs/skill-taskless/spec.md @@ -28,13 +28,26 @@ The skills bundle SHALL contain exactly one skill named `taskless`. This skill S ### Requirement: Skill description anchors triggers on Taskless-specific phrases -The consolidated skill's `description` frontmatter field SHALL anchor triggers on either an explicit reference to "Taskless" in the user's message OR a reference to the `.taskless/` directory or files within it (rules, rule-tests, rule-metadata). The description SHALL explicitly instruct the agent NOT to trigger on generic ESLint, linting, or rule requests that don't reference Taskless. +The consolidated skill's `description` frontmatter field SHALL anchor triggers on: + +1. An explicit reference to "Taskless" in the user's message, OR +2. A reference to the `.taskless/` directory or files within it (rules, rule-tests, rule-metadata), OR +3. A request to add/write/create a rule where the user has NOT named a specific lint/format/static-analysis tool. The description SHALL include four illustrative example tools whose presence in the user's message suppresses this trigger: eslint, ruff, biome, ast-grep. The wording SHALL make clear the list is illustrative — any named lint/format/static-analysis tool suppresses the trigger. + +The description SHALL NOT contain a blanket "do NOT trigger on generic linting" instruction; that prior carve-out is replaced by the named-tool suppression in clause 3. #### Scenario: Description includes anchored trigger phrases - **WHEN** the skill `description` field is read - **THEN** it SHALL include trigger phrases such as "create/add/write a taskless rule", "improve/fix/iterate on this taskless rule", "run taskless", "taskless login", "add taskless to CI" -- **AND** SHALL include an explicit "Do NOT trigger on" clause covering generic ESLint and linting requests +- **AND** SHALL include the unspecified-tool clause covering "add/write/create a rule" with no tool named +- **AND** SHALL list at least the four illustrative suppressing tool names: eslint, ruff, biome, ast-grep + +#### Scenario: Description omits the prior blanket carve-out + +- **WHEN** the skill `description` field is read +- **THEN** it SHALL NOT contain wording instructing the agent to never trigger on generic ESLint/linting requests +- **AND** any suppression wording SHALL be expressed via the named-tool clause #### Scenario: Description is at most 1024 characters @@ -45,23 +58,29 @@ The consolidated skill's `description` frontmatter field SHALL anchor triggers o The consolidated skill body SHALL NOT contain step-by-step instructions for any individual Taskless task. The body SHALL be a router that: -1. States explicitly that the agent does NOT have the steps for any Taskless action in its context -2. Instructs the agent to fetch the canonical recipe via `npx @taskless/cli help ` before proceeding -3. Provides a topic disambiguation table mapping user intents to topic names -4. Includes a `## --anonymous` section explaining the global flag's behavior -5. Includes a first-step `.taskless/` presence check with graceful failure ("ask the user to confirm they meant Taskless") +1. States explicitly that the agent does NOT have the steps for any Taskless action in its context. +2. Instructs the agent to fetch the canonical recipe via `npx @taskless/cli help ` before proceeding. +3. Provides a topic disambiguation table mapping user intents to topic names. The table SHALL include a row for the new `onboard` topic. +4. Includes a `## --anonymous` section explaining the global flag's behavior. +5. Includes a first-step `.taskless/` presence check with graceful failure ("ask the user to confirm they meant Taskless"). +6. Includes a `## Quiet suggestion` (or equivalently named) section governing the proactive trigger introduced via the description's named-tool clause. This section SHALL specify that: + - When the skill triggers because the user wants to add a rule and has not named a specific tool, the agent SHALL surface a single-line offer to capture the rule via Taskless rather than launching into a full recipe (e.g., "I can capture this as a Taskless rule if you want — say so, or I'll proceed with X"). + - If the user declines or ignores the offer, the agent SHALL proceed with whatever it would have done without the skill. + - If the user declines, the agent SHALL NOT re-offer Taskless in the same conversation. No persistent decline state SHALL be written to disk. + - If the user accepts, the skill router SHALL proceed normally to fetch `npx @taskless/cli help rule create`. -The body SHALL be no more than 60 lines of markdown to keep the always-loaded surface small. +The body SHALL be no more than 80 lines of markdown to keep the always-loaded surface small. (The previous 60-line cap is relaxed to accommodate the new quiet-suggestion section and the `onboard` row.) #### Scenario: Skill body warns against improvising - **WHEN** the skill body is read by an agent - **THEN** it SHALL contain explicit framing such as "You do NOT have the steps... do not improvise from prior knowledge" -#### Scenario: Skill body lists available topics +#### Scenario: Skill body lists available topics including onboard - **WHEN** the skill body is read by an agent -- **THEN** it SHALL include a table or list mapping user intents (create rule, improve rule, delete rule, check, auth, ci) to the corresponding `tskl help ` invocations +- **THEN** it SHALL include a table or list mapping user intents to the corresponding `tskl help ` invocations +- **AND** the table SHALL include a row for `onboard` mapped to `npx @taskless/cli help onboard` (or equivalent invocation of the onboard topic) #### Scenario: Skill body checks for .taskless directory @@ -69,6 +88,23 @@ The body SHALL be no more than 60 lines of markdown to keep the always-loaded su - **THEN** the body's first step SHALL instruct the agent to check whether `.taskless/` exists in the working directory - **AND** to ask the user to confirm Taskless is what they meant if the directory is absent +#### Scenario: Skill body specifies quiet suggestion behavior + +- **WHEN** the skill is triggered by the unspecified-tool clause from the description +- **THEN** the body's quiet-suggestion section SHALL instruct the agent to surface a single-line offer rather than a full recipe +- **AND** SHALL instruct the agent NOT to re-offer in the same conversation if declined +- **AND** SHALL specify that no persistent decline state is written + +#### Scenario: Skill body specifies in-conversation decline is sticky + +- **WHEN** the user has declined a quiet-suggestion offer once in the current conversation +- **THEN** the body SHALL instruct the agent not to surface the offer again in the same conversation + +#### Scenario: Skill body length cap + +- **WHEN** the skill body is measured +- **THEN** it SHALL be no more than 80 lines of markdown + ### Requirement: Skill maps to a single tskl command The consolidated skill's frontmatter SHALL include `metadata.commandName: tskl` so that command-installation plumbing maps the skill to the new single command file at `commands/tskl/tskl.md`. The command file SHALL be a thin doorway that accepts a free-form `$ARGUMENTS` ask, infers a topic if possible, and otherwise asks the user what they want to do. diff --git a/packages/cli/README.md b/packages/cli/README.md index 3f53129..7541822 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -54,6 +54,38 @@ selected. Upgrading from v0.6 automatically removes the obsolete per-task skills and commands during this diff. Cancelling the wizard at any step (Ctrl-C) aborts cleanly with no filesystem changes. +### `taskless onboard` + +Post-install discovery flow that helps a fresh user go from zero rules to a +useful starter set. Run it after `taskless init`. The CLI prints an +agent-facing recipe that walks the host AI tool through scanning the +codebase, agent-memory files (CLAUDE.md / AGENTS.md / .cursorrules), +recent PR review comments (when `gh` is available), and issue tracker +tickets (when a relevant MCP is wired in) for high-signal rule +candidates, then surfaces them as a bullet list the user can choose to +materialize via `taskless rule create`. + +```bash +taskless onboard # print the recipe (refused if already complete) +taskless onboard --force # re-run even when previously marked complete +taskless onboard --mark-complete # record completion in .taskless/taskless.json + # (invoked by the agent after explicit user + # confirmation; never automatically) +``` + +Onboarding state lives in `.taskless/taskless.json` as +`install.onboarded` — a 3-state optional field (absent / `false` / `true`). +Only the agent writes it, and only with the user's explicit confirmation. +`taskless init` does not set it. Pass `--force` to re-run regardless of the +current value. + +After a successful `taskless init`, the CLI prints a one-line trailer +pointing the user at this command. The trailer wording adapts to the +install plan: when the install included slash commands (Claude Code or +Cursor), it mentions `/tskl onboard` along with the Taskless skill and the +bare CLI; when the install only wrote skills (OpenCode, Codex, the +`.agents/` fallback), it mentions the skill and the bare CLI only. + ### `taskless check` Run ast-grep rules from `.taskless/rules/` against the codebase. Exits with code 1 if any error-severity matches are found. diff --git a/packages/cli/package.json b/packages/cli/package.json index a245349..59e6eac 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -36,10 +36,12 @@ "openapi-fetch": "^0.17.0", "picocolors": "^1.1.1", "posthog-node": "^5.28.11", + "sprintf-js": "^1.1.3", "yaml": "^2.8.2", "zod": "^4.3.6" }, "devDependencies": { + "@types/sprintf-js": "^1.1.4", "openapi-typescript": "^7.13.0", "typescript": "^5.7.2", "vite": "^7.3.1", diff --git a/packages/cli/src/commands/help.ts b/packages/cli/src/commands/help.ts index 4d55e00..7179385 100644 --- a/packages/cli/src/commands/help.ts +++ b/packages/cli/src/commands/help.ts @@ -6,6 +6,7 @@ import { type Resolvable, type SubCommandsDef, } from "citty"; +import { sprintf } from "sprintf-js"; import { z } from "zod"; import { getTelemetry } from "../telemetry"; @@ -49,7 +50,7 @@ function buildHelpMaps(): { const { helpMap, anonymousMap } = buildHelpMaps(); -// Topic → Zod input schema. When a recipe contains the {{INPUT_SCHEMA}} +// Topic → Zod input schema. When a recipe contains the %(INPUT_SCHEMA)s // placeholder, the help command substitutes the JSON Schema rendered // from this Zod source. const TOPIC_INPUT_SCHEMAS: Record = { @@ -57,17 +58,46 @@ const TOPIC_INPUT_SCHEMAS: Record = { "rule-improve": ruleImproveInputSchema, }; +/** + * Render a recipe by interpolating sprintf-js named arguments. The recipe + * source uses `%(KEY)s` placeholders; the variable table built here resolves + * each known placeholder to its rendered string. Recipes that contain a + * literal `%` character must escape it as `%%` per sprintf-js conventions. + * + * Two flavors of substitution coexist in the variables table: + * - System-resolved values (e.g. `CLI_VERSION`) — rendered to a real value. + * - Agent-fill markers (e.g. `PACKAGE_MANAGER_DLX`) — rendered as + * `` so the consuming agent knows to substitute. + */ function renderRecipe(content: string, topic: string): string { - let out = content; - out = out.replaceAll("{{CLI_VERSION}}", __VERSION__); - if (out.includes("{{INPUT_SCHEMA}}")) { + const variables: Record = { + CLI_VERSION: __VERSION__, + PACKAGE_MANAGER_DLX: "", + }; + if (content.includes("%(INPUT_SCHEMA)s")) { const schema = TOPIC_INPUT_SCHEMAS[topic]; - const rendered = schema + variables.INPUT_SCHEMA = schema ? JSON.stringify(z.toJSONSchema(schema), null, 2) : "(no input schema for this topic)"; - out = out.replaceAll("{{INPUT_SCHEMA}}", rendered); } - return out; + return sprintf(content, variables); +} + +/** + * Look up a help topic from the embedded recipe map and return the rendered + * text. Anonymous variants are preferred when `anonymous` is set and a + * variant exists; otherwise the canonical recipe is returned. Returns + * `undefined` when the topic is unknown. + */ +export function getRecipe( + topic: string, + options: { anonymous?: boolean } = {} +): string | undefined { + const content = options.anonymous + ? (anonymousMap.get(topic) ?? helpMap.get(topic)) + : helpMap.get(topic); + if (content === undefined) return undefined; + return renderRecipe(content, topic); } async function unwrap(resolvable: Resolvable): Promise { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 28ff04d..a38843e 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -15,6 +15,8 @@ import { getTelemetry } from "../telemetry"; import { runWizard } from "../wizard"; import { getCliVersion } from "../wizard/intro"; +import { getOnboardTrailer } from "./onboard"; + function shouldRunInteractively(noInteractiveFlag: boolean): boolean { if (noInteractiveFlag) return false; if (process.env.CI === "true" || process.env.CI === "1") return false; @@ -69,7 +71,10 @@ export const initCommand = defineCommand({ } const start = Date.now(); - await runNonInteractive(cwd); + const result = await runNonInteractive(cwd); + console.log( + getOnboardTrailer({ commandsInstalled: result.commandsInstalled }) + ); telemetry.capture("cli_init_completed", { locations: await detectedLocationDirectories(cwd), optionalSkills: [], @@ -119,7 +124,9 @@ export const updateCommand = defineCommand({ }, }); -async function runNonInteractive(cwd: string): Promise { +async function runNonInteractive( + cwd: string +): Promise<{ commandsInstalled: boolean }> { await ensureTasklessDirectory(cwd); const allSkills = getEmbeddedSkills(); @@ -148,6 +155,8 @@ async function runNonInteractive(cwd: string): Promise { }); } + const commandsInstalled = planTargets.some((t) => t.commands.length > 0); + const result = await applyInstallPlan( cwd, { targets: planTargets }, @@ -218,6 +227,8 @@ async function runNonInteractive(cwd: string): Promise { } } } + + return { commandsInstalled }; } function groupValuesByTarget( diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts new file mode 100644 index 0000000..19a078f --- /dev/null +++ b/packages/cli/src/commands/onboard.ts @@ -0,0 +1,109 @@ +import { resolve } from "node:path"; + +import { defineCommand } from "citty"; + +import { ensureTasklessDirectory } from "../filesystem/directory"; +import { readManifest, writeManifest } from "../filesystem/migrate"; +import { getTelemetry } from "../telemetry"; +import { CliError } from "../util/cli-error"; + +import { getRecipe } from "./help"; + +/** + * One-line trailer printed by `taskless init` (and the wizard) after a + * successful install. Lives here so the install paths share the same + * wording with the onboard subcommand they point at. + * + * Branches on whether any installed target received the `tskl` slash + * command. Tools that get commands (Claude Code, Cursor) also get the + * Taskless skill, so the with-commands trailer mentions both AI-tool + * paths; tools without commands (OpenCode, Codex, `.agents` fallback) + * still get the skill, so the no-commands trailer points at the skill. + * Both trailers also mention `taskless onboard` as the bare CLI fallback. + */ +export function getOnboardTrailer(args: { + commandsInstalled: boolean; +}): string { + if (args.commandsInstalled) { + return "Next: in your AI tool, run /tskl onboard or ask it to use the Taskless skill (or run `taskless onboard` from your terminal) to discover rule candidates from your codebase."; + } + return "Next: in your AI tool, ask it to use the Taskless skill (or run `taskless onboard` from your terminal) to discover rule candidates from your codebase."; +} + +export const onboardCommand = defineCommand({ + meta: { + name: "onboard", + description: + "Discover rule candidates from your codebase and history (post-install flow)", + }, + args: { + dir: { + type: "string", + alias: "d", + description: "Working directory", + }, + force: { + type: "boolean", + description: + "Re-run the recipe even when onboarding is already marked complete", + default: false, + }, + "mark-complete": { + type: "boolean", + description: + "Record onboarding as complete in .taskless/taskless.json (invoked by the agent after explicit user confirmation)", + default: false, + }, + }, + async run({ args }) { + const cwd = resolve(args.dir ?? process.cwd()); + const telemetry = await getTelemetry(cwd); + + if (args.force && args["mark-complete"]) { + console.error( + "Error: --force and --mark-complete cannot be used together." + ); + console.error( + " --force re-runs the discovery recipe; --mark-complete records completion." + ); + process.exitCode = 1; + throw new CliError("conflicting flags"); + } + + await ensureTasklessDirectory(cwd); + const tasklessDirectory = resolve(cwd, ".taskless"); + + if (args["mark-complete"]) { + const { manifest, raw } = await readManifest(tasklessDirectory); + const install = manifest.install ?? {}; + install.onboarded = true; + manifest.install = install; + await writeManifest(tasklessDirectory, manifest, raw); + console.log("Marked Taskless onboarding as complete."); + telemetry.capture("cli_onboard_marked_complete"); + return; + } + + const { manifest } = await readManifest(tasklessDirectory); + const alreadyOnboarded = manifest.install?.onboarded === true; + + if (alreadyOnboarded && !args.force) { + console.log("Taskless onboarding is already marked complete."); + console.log( + "Run `taskless onboard --force` to re-run the discovery recipe." + ); + telemetry.capture("cli_onboard_already_done"); + return; + } + + const recipe = getRecipe("onboard"); + if (recipe === undefined) { + // Should not happen — onboard.txt is embedded at build time. + console.error("Internal error: onboard recipe is not available."); + process.exitCode = 1; + throw new CliError("recipe missing"); + } + console.log(recipe.trimEnd()); + telemetry.capture("cli_onboard_recipe", { forced: args.force }); + }, +}); diff --git a/packages/cli/src/filesystem/migrate.ts b/packages/cli/src/filesystem/migrate.ts index 9d18356..be8d1a8 100644 --- a/packages/cli/src/filesystem/migrate.ts +++ b/packages/cli/src/filesystem/migrate.ts @@ -14,6 +14,7 @@ export interface TasklessInstallManifest { installedAt?: string; cliVersion?: string; targets?: Record; + onboarded?: boolean; } export interface TasklessManifest { diff --git a/packages/cli/src/help/auth.txt b/packages/cli/src/help/auth.txt index 9bb7dce..7c69bc8 100644 --- a/packages/cli/src/help/auth.txt +++ b/packages/cli/src/help/auth.txt @@ -1,4 +1,4 @@ -# Topic: auth (CLI v{{CLI_VERSION}} / topic v1) +# Topic: auth (CLI v%(CLI_VERSION)s / topic v1) ## Goal Manage Taskless authentication. Three branches: diff --git a/packages/cli/src/help/check.txt b/packages/cli/src/help/check.txt index f396ea4..76111b6 100644 --- a/packages/cli/src/help/check.txt +++ b/packages/cli/src/help/check.txt @@ -1,4 +1,4 @@ -# Topic: check (CLI v{{CLI_VERSION}} / topic v1) +# Topic: check (CLI v%(CLI_VERSION)s / topic v1) ## Goal Run all rules in `.taskless/rules/` against the codebase and report diff --git a/packages/cli/src/help/ci.txt b/packages/cli/src/help/ci.txt index b3086bd..320216e 100644 --- a/packages/cli/src/help/ci.txt +++ b/packages/cli/src/help/ci.txt @@ -1,4 +1,4 @@ -# Topic: ci (CLI v{{CLI_VERSION}} / topic v1) +# Topic: ci (CLI v%(CLI_VERSION)s / topic v1) ## Goal Wire `taskless check` into the user's existing CI so rules run @@ -95,7 +95,7 @@ Canonical paths: The reference template — translate the same shape (checkout with full history, set up Node, conditional check) for other CIs. -Substitute `{{PACKAGE_MANAGER_DLX}}` with `npx @taskless/cli`, +Substitute `%(PACKAGE_MANAGER_DLX)s` with `npx @taskless/cli`, `pnpm dlx @taskless/cli`, `yarn dlx @taskless/cli`, or `bunx @taskless/cli` based on `pnpm-lock.yaml`/`yarn.lock`/`bun.lockb`. @@ -124,21 +124,21 @@ jobs: - name: Taskless check run: | - if [ "${{ '{{ github.event_name }}' }}" = "pull_request" ]; then - git fetch origin "${{ '{{ github.base_ref }}' }}" --depth=1 - FILES=$(git diff --name-only "origin/${{ '{{ github.base_ref }}' }}...HEAD") + if [ "${{ github.event_name }}" = "pull_request" ]; then + git fetch origin "${{ github.base_ref }}" --depth=1 + FILES=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD") if [ -z "$FILES" ]; then echo "No changed files." exit 0 fi - {{PACKAGE_MANAGER_DLX}} check $FILES + %(PACKAGE_MANAGER_DLX)s check $FILES else - {{PACKAGE_MANAGER_DLX}} check + %(PACKAGE_MANAGER_DLX)s check fi ``` Simplifications: -- Full-scan only → drop the `if`, just run `{{PACKAGE_MANAGER_DLX}} check`. +- Full-scan only → drop the `if`, just run `%(PACKAGE_MANAGER_DLX)s check`. - Diff-scan only → remove the `push:` trigger. ### 6. Translate to other CIs @@ -149,8 +149,8 @@ The six universal steps: 3. Fetch the target branch. 4. Compute changed files with `git diff --name-only "origin/...HEAD"`. 5. Exit early if the diff is empty. -6. Call `{{PACKAGE_MANAGER_DLX}} check $FILES` for PR builds, or - `{{PACKAGE_MANAGER_DLX}} check` for main-branch builds. +6. Call `%(PACKAGE_MANAGER_DLX)s check $FILES` for PR builds, or + `%(PACKAGE_MANAGER_DLX)s check` for main-branch builds. YAML for GitHub/GitLab/Azure/Bitbucket; Groovy for Jenkins; different structure for CircleCI. The six steps stay the same. diff --git a/packages/cli/src/help/info.txt b/packages/cli/src/help/info.txt index 84c43f0..6870a24 100644 --- a/packages/cli/src/help/info.txt +++ b/packages/cli/src/help/info.txt @@ -1,4 +1,4 @@ -# Topic: info (CLI v{{CLI_VERSION}} / topic v1) +# Topic: info (CLI v%(CLI_VERSION)s / topic v1) ## Goal Report local Taskless state: CLI version, installed skill versions diff --git a/packages/cli/src/help/init.txt b/packages/cli/src/help/init.txt index 301fbbb..8f7973e 100644 --- a/packages/cli/src/help/init.txt +++ b/packages/cli/src/help/init.txt @@ -1,4 +1,4 @@ -# Topic: init (CLI v{{CLI_VERSION}} / topic v1) +# Topic: init (CLI v%(CLI_VERSION)s / topic v1) ## Goal Install or update the Taskless skill into the user's coding-agent diff --git a/packages/cli/src/help/onboard.txt b/packages/cli/src/help/onboard.txt new file mode 100644 index 0000000..58105cb --- /dev/null +++ b/packages/cli/src/help/onboard.txt @@ -0,0 +1,122 @@ +# Topic: onboard (CLI v%(CLI_VERSION)s / topic v1) + +## Goal +Help a user who has just installed Taskless go from zero rules to a +useful starter set by mining their codebase, agent-memory files, PR +review history, and issue tracker for high-signal rule candidates. +This is a conversational discovery flow, not a script — the agent +collaborates with the user on what to scan and surfaces hypothetical +rules as a bullet list the user can choose to materialize via the +`rule create` flow. + +## Preconditions +- `.taskless/` directory exists (Taskless is installed). The + `taskless onboard` subcommand bootstraps it on first run, so this + is automatically satisfied. +- A working repository the agent can read. +- No auth required to surface candidates. (Materializing a rule via + `rule create` may require auth — fetch `taskless help auth` if + needed at that point.) + +## Steps + +1. **Read the manifest first.** Run + `npx @taskless/cli info --json` and check whether + `.taskless/taskless.json` reports `install.onboarded: true`. The + `taskless onboard` subcommand has already gated on this for you, + but if the user is invoking the recipe directly via the skill, + confirm with the user before running a long discovery pass. + +2. **Open the conversation about sources.** Tell the user you can mine + several places for rule candidates and ask which ones they want to + include. Default sources you should always offer: + + - **Codebase TODOs / FIXMEs** — search for `TODO`, `FIXME`, `XXX`, + and `HACK` comments. Many of these are latent rules ("don't do + this", "remove when X"). + - **Agent-memory files** — read `CLAUDE.md`, `AGENTS.md`, + `.cursorrules`, `.opencode/AGENTS.md`, and similar agent-context + files for explicit rules and conventions stated in prose. + - **Recent PR review comments** — only if the `gh` CLI is + available. Probe with `command -v gh`. Suggest scanning the last + 30 days of merged PRs for repeated reviewer feedback patterns. + - **Issue tracker tickets** — only if a relevant MCP is wired in + (Linear, Jira, GitHub issues via `gh issue list`, etc.). Use + whatever issue-tracker tools you have available. + + Then explicitly ask: "Are there other places I should look — a + team wiki, an internal docs site, a specific design doc, a Slack + channel export?" The user often knows about sources you don't. + +3. **Probe tool availability before promising a scan.** For each + source the user picks, verify the tool exists before committing + to it. Don't tell the user "I'll scan PR comments" if `gh` isn't + installed — say "PR comments need the GitHub CLI, or equivalent; want me to skip + this or wait while you install these tools?" + +4. **Scan with high-signal filtering.** For each chosen source: + + - **Filter for repeated patterns.** A reviewer comment that + appears across multiple PRs is a much stronger rule candidate + than a one-off nit. + - **Prefer comments that cite a doc or style guide.** "Per our + style guide, no direct DB calls in controllers" is a rule. + "looks good" is not. + - **Prefer merge-blocking feedback.** Comments that gated a PR + merge are higher-signal than passing remarks. + - **Filter out one-off nits and pure formatting.** Linters and + formatters already handle those; Taskless rules earn their + keep on semantic patterns. + +5. **Synthesize the bullet list.** Present findings as a single + bullet list the user can scan quickly. One bullet per + hypothetical rule, in this format: + + ``` + - : + ``` + + Example: + + ``` + - no-direct-db-access: Prevent controllers from importing the DB module directly; route through the service layer. + - require-zod-validation: Flag API handlers that read request bodies without parsing them through a Zod schema first. + - no-console-log-in-handlers: Disallow console.log inside HTTP handlers; use the structured logger instead. + ``` + + Order the bullets by your judgment of impact (frequency in the + source × severity of the issue × ease of enforcement). Don't + inflate the list — three high-quality candidates beat ten + speculative ones. + +6. **Offer materialization per bullet.** For each bullet, ask + whether the user wants to turn it into a real Taskless rule. On + yes, fetch `taskless help rule create` and follow that recipe — + the user's accepted bullet becomes the rule description input. + +7. **Ask before marking onboarding complete.** When the user signals + they're done (they've materialized everything they want, or + they've said "that's enough for now"), explicitly ask: "Do you + want me to mark Taskless as onboarded? You won't be re-asked to + onboard until you pass `--force`." On explicit yes — and only + on explicit yes — run: + + ``` + npx @taskless/cli onboard --mark-complete + ``` + + Do NOT mark onboarding complete on your own initiative. Do NOT + run `--mark-complete` if the user is ambiguous, says "maybe + later", or simply stops responding. The flag is consent-gated. + +## Errors + +| code | meaning | fix | +|---------------------|-----------------------------------------------|--------------------------------------| +| `ALREADY_ONBOARDED` | `install.onboarded` is true and no `--force` | suggest `--force` or skip onboarding | + +## See Also + +- `taskless help rule create` — materialize an accepted bullet into a real rule +- `taskless help check` — validate newly created rules against the codebase +- `taskless help info` — inspect the current `.taskless/taskless.json` state diff --git a/packages/cli/src/help/rule-create.anonymous.txt b/packages/cli/src/help/rule-create.anonymous.txt index ec50f3c..5d2fe31 100644 --- a/packages/cli/src/help/rule-create.anonymous.txt +++ b/packages/cli/src/help/rule-create.anonymous.txt @@ -1,4 +1,4 @@ -# Topic: rule create (anonymous) (CLI v{{CLI_VERSION}} / topic v1) +# Topic: rule create (anonymous) (CLI v%(CLI_VERSION)s / topic v1) ## Goal Create a new ast-grep rule **locally** without contacting the Taskless diff --git a/packages/cli/src/help/rule-create.txt b/packages/cli/src/help/rule-create.txt index 9a9bba6..f36e67f 100644 --- a/packages/cli/src/help/rule-create.txt +++ b/packages/cli/src/help/rule-create.txt @@ -1,4 +1,4 @@ -# Topic: rule create (CLI v{{CLI_VERSION}} / topic v1) +# Topic: rule create (CLI v%(CLI_VERSION)s / topic v1) ## Goal Generate a new ast-grep rule from a description and write the rule and @@ -75,7 +75,7 @@ If the user wants the local-only flow (no API call), fetch The `--from` JSON file conforms to: ```json -{{INPUT_SCHEMA}} +%(INPUT_SCHEMA)s ``` Each example in `successCases` and `failureCases` is a separate diff --git a/packages/cli/src/help/rule-delete.txt b/packages/cli/src/help/rule-delete.txt index d2acef9..c4bbfc7 100644 --- a/packages/cli/src/help/rule-delete.txt +++ b/packages/cli/src/help/rule-delete.txt @@ -1,4 +1,4 @@ -# Topic: rule delete (CLI v{{CLI_VERSION}} / topic v1) +# Topic: rule delete (CLI v%(CLI_VERSION)s / topic v1) ## Goal Remove a rule and its associated test files from `.taskless/`. Does diff --git a/packages/cli/src/help/rule-improve.anonymous.txt b/packages/cli/src/help/rule-improve.anonymous.txt index ddaa34c..f8b8b75 100644 --- a/packages/cli/src/help/rule-improve.anonymous.txt +++ b/packages/cli/src/help/rule-improve.anonymous.txt @@ -1,4 +1,4 @@ -# Topic: rule improve (anonymous) (CLI v{{CLI_VERSION}} / topic v1) +# Topic: rule improve (anonymous) (CLI v%(CLI_VERSION)s / topic v1) ## Goal Iterate on an existing ast-grep rule **locally** without contacting diff --git a/packages/cli/src/help/rule-improve.txt b/packages/cli/src/help/rule-improve.txt index 216f6f8..adfdbf2 100644 --- a/packages/cli/src/help/rule-improve.txt +++ b/packages/cli/src/help/rule-improve.txt @@ -1,4 +1,4 @@ -# Topic: rule improve (CLI v{{CLI_VERSION}} / topic v1) +# Topic: rule improve (CLI v%(CLI_VERSION)s / topic v1) ## Goal Iterate on an existing Taskless rule. The CLI submits the user's @@ -77,7 +77,7 @@ If the user wants the local-only flow (no API call), fetch The `--from` JSON file conforms to: ```json -{{INPUT_SCHEMA}} +%(INPUT_SCHEMA)s ``` `ruleId` is the original rule's ticket ID (returned by diff --git a/packages/cli/src/help/rule-meta.txt b/packages/cli/src/help/rule-meta.txt index 3dfe7b3..d9361ef 100644 --- a/packages/cli/src/help/rule-meta.txt +++ b/packages/cli/src/help/rule-meta.txt @@ -1,4 +1,4 @@ -# Topic: rule meta (CLI v{{CLI_VERSION}} / topic v1) +# Topic: rule meta (CLI v%(CLI_VERSION)s / topic v1) ## Goal Read sidecar metadata for an API-generated rule. Used internally by diff --git a/packages/cli/src/help/rule-verify.txt b/packages/cli/src/help/rule-verify.txt index 47db364..4517d3f 100644 --- a/packages/cli/src/help/rule-verify.txt +++ b/packages/cli/src/help/rule-verify.txt @@ -1,4 +1,4 @@ -# Topic: rule verify (CLI v{{CLI_VERSION}} / topic v1) +# Topic: rule verify (CLI v%(CLI_VERSION)s / topic v1) ## Goal Validate a rule against the ast-grep schema and run its test cases. diff --git a/packages/cli/src/help/rule.txt b/packages/cli/src/help/rule.txt index f1a2340..05499fa 100644 --- a/packages/cli/src/help/rule.txt +++ b/packages/cli/src/help/rule.txt @@ -1,4 +1,4 @@ -# Topic: rule (CLI v{{CLI_VERSION}} / topic v1) +# Topic: rule (CLI v%(CLI_VERSION)s / topic v1) ## Goal Umbrella for rule operations. Use the specific subcommand for the diff --git a/packages/cli/src/help/update.txt b/packages/cli/src/help/update.txt index 8571c23..71739ba 100644 --- a/packages/cli/src/help/update.txt +++ b/packages/cli/src/help/update.txt @@ -1,4 +1,4 @@ -# Topic: update (CLI v{{CLI_VERSION}} / topic v1) +# Topic: update (CLI v%(CLI_VERSION)s / topic v1) ## Goal Update Taskless skills in the user's coding-agent tools to the diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 475f8b7..2b9060c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,6 +5,7 @@ import { checkCommand } from "./commands/check"; import { initCommand, updateCommand } from "./commands/init"; import { infoCommand } from "./commands/info"; import { createHelpCommand } from "./commands/help"; +import { onboardCommand } from "./commands/onboard"; import { ruleCommand } from "./commands/rules"; import { shutdownTelemetry } from "./telemetry"; import { CliError } from "./util/cli-error"; @@ -15,6 +16,7 @@ const subCommands = { info: infoCommand, check: checkCommand, auth: authCommand, + onboard: onboardCommand, rule: ruleCommand, }; diff --git a/packages/cli/src/install/state.ts b/packages/cli/src/install/state.ts index 30d9a39..ce88d55 100644 --- a/packages/cli/src/install/state.ts +++ b/packages/cli/src/install/state.ts @@ -85,7 +85,12 @@ export async function writeInstallState( ): Promise { const tasklessDirectory = join(cwd, TASKLESS_DIR); const { manifest, raw } = await readManifest(tasklessDirectory); - manifest.install = toInstallManifest(state); + // Shallow-merge so any sibling fields under `install` that don't round-trip + // through `InstallState` (e.g. `onboarded`, or future opt-in flags) survive + // re-installs. Newly-computed fields from `toInstallManifest(state)` win + // over previous values for the same key. + const previousInstall = manifest.install ?? {}; + manifest.install = { ...previousInstall, ...toInstallManifest(state) }; await writeManifest(tasklessDirectory, manifest, raw); } diff --git a/packages/cli/src/wizard/index.ts b/packages/cli/src/wizard/index.ts index 0cfe465..aa79f4d 100644 --- a/packages/cli/src/wizard/index.ts +++ b/packages/cli/src/wizard/index.ts @@ -1,5 +1,6 @@ import { intro, outro, cancel, log } from "@clack/prompts"; +import { getOnboardTrailer } from "../commands/onboard"; import { ensureTasklessDirectory } from "../filesystem/directory"; import { applyInstallPlan, @@ -120,6 +121,8 @@ export async function runWizard( ); outro("Taskless is ready to go."); + const commandsInstalled = planTargets.some((t) => t.commands.length > 0); + console.log(getOnboardTrailer({ commandsInstalled })); return finish({ status: "completed" }); } catch (error) { if (error instanceof WizardCancelled) { diff --git a/packages/cli/test/help-extensions.test.ts b/packages/cli/test/help-extensions.test.ts index 099826d..998dd55 100644 --- a/packages/cli/test/help-extensions.test.ts +++ b/packages/cli/test/help-extensions.test.ts @@ -79,22 +79,37 @@ describe("taskless help ", () => { expect(result.stdout).toContain("## Steps"); }); - it("interpolates {{CLI_VERSION}} in the recipe header", async () => { + it("interpolates %(CLI_VERSION)s in the recipe header", async () => { const result = await runCli(["help", "rule", "create", "-d", cwd]); - // Should contain a version pattern, not the literal placeholder + // Should contain a version pattern, not the literal placeholder. + // Guard against both the legacy mustache syntax and the current + // sprintf-js named-arg syntax leaking through. expect(result.stdout).not.toContain("{{CLI_VERSION}}"); + expect(result.stdout).not.toContain("%(CLI_VERSION)s"); expect(result.stdout).toMatch(/CLI v\d+\.\d+\.\d+/); }); - it("interpolates {{INPUT_SCHEMA}} for topics with a Zod input", async () => { + it("interpolates %(INPUT_SCHEMA)s for topics with a Zod input", async () => { const result = await runCli(["help", "rule", "create", "-d", cwd]); expect(result.stdout).not.toContain("{{INPUT_SCHEMA}}"); + expect(result.stdout).not.toContain("%(INPUT_SCHEMA)s"); // Embedded schema includes the JSON Schema $schema URI expect(result.stdout).toContain('"$schema"'); expect(result.stdout).toContain('"prompt"'); expect(result.stdout).toContain('"successCases"'); }); + it("renders %(PACKAGE_MANAGER_DLX)s as the agent-fill marker", async () => { + const result = await runCli(["help", "ci", "-d", cwd]); + expect(result.exitCode).toBe(0); + // Sprintf substitutes the placeholder; the agent-fill marker should + // appear in its rendered form, never as the + // raw sprintf placeholder. + expect(result.stdout).not.toContain("%(PACKAGE_MANAGER_DLX)s"); + expect(result.stdout).not.toContain("{{PACKAGE_MANAGER_DLX}}"); + expect(result.stdout).toContain(""); + }); + it("exits 1 for an unknown topic", async () => { const result = await runCli(["help", "totally-unknown", "-d", cwd]); expect(result.exitCode).not.toBe(0); diff --git a/packages/cli/test/init-no-interactive.test.ts b/packages/cli/test/init-no-interactive.test.ts index 622596b..b6d9c7f 100644 --- a/packages/cli/test/init-no-interactive.test.ts +++ b/packages/cli/test/init-no-interactive.test.ts @@ -142,4 +142,83 @@ describe("taskless init --no-interactive", () => { expect(manifest.version).toBe(2); expect(manifest.install).toBeDefined(); }); + + it("prints a trailer mentioning /tskl onboard when commands were installed", async () => { + // Claude Code receives commands, so the trailer should mention the + // slash command form and the skill (both work) plus the bare CLI. + await mkdir(join(cwd, ".claude"), { recursive: true }); + + const { stdout } = await execFileAsync("node", [ + binPath, + "init", + "--no-interactive", + "-d", + cwd, + ]); + + expect(stdout).toMatch(/Next:.*\/tskl onboard/); + expect(stdout).toMatch(/Taskless skill/); + expect(stdout).toMatch(/`taskless onboard`/); + }); + + it("prints a skill-only trailer when no commands were installed", async () => { + // .agents/ fallback receives no commands. The trailer should NOT mention + // /tskl onboard but SHOULD mention the skill and the bare CLI. + const { stdout } = await execFileAsync("node", [ + binPath, + "init", + "--no-interactive", + "-d", + cwd, + ]); + + expect(stdout).not.toContain("/tskl onboard"); + expect(stdout).toMatch(/Taskless skill/); + expect(stdout).toMatch(/`taskless onboard`/); + }); + + it("`taskless update` does NOT print the onboarding trailer", async () => { + // Update is the same install plumbing but the trailer is scoped to init. + await mkdir(join(cwd, ".claude"), { recursive: true }); + + const { stdout } = await execFileAsync("node", [ + binPath, + "update", + "-d", + cwd, + ]); + + expect(stdout).not.toMatch(/Next:.*onboard/); + expect(stdout).not.toContain("/tskl onboard"); + }); + + it("prints the trailer (and preserves install.onboarded) when re-running init on top of onboarded:true", async () => { + // Re-install on top of an existing onboarded:true manifest. The trailer + // is informational, not gated on the manifest state, so it MUST still + // print. install.onboarded MUST survive the re-install (writeInstallState + // preserves it explicitly so init never silently wipes onboarding). + await mkdir(join(cwd, ".claude"), { recursive: true }); + await mkdir(join(cwd, ".taskless"), { recursive: true }); + const { writeFile } = await import("node:fs/promises"); + await writeFile( + join(cwd, ".taskless", "taskless.json"), + JSON.stringify({ version: 2, install: { onboarded: true } }), + "utf8" + ); + + const { stdout } = await execFileAsync("node", [ + binPath, + "init", + "--no-interactive", + "-d", + cwd, + ]); + + expect(stdout).toMatch(/Next:.*onboard/); + + const manifest = JSON.parse( + await readFile(join(cwd, ".taskless", "taskless.json"), "utf8") + ) as { install?: { onboarded?: boolean } }; + expect(manifest.install?.onboarded).toBe(true); + }); }); diff --git a/packages/cli/test/onboard.test.ts b/packages/cli/test/onboard.test.ts new file mode 100644 index 0000000..e3692a0 --- /dev/null +++ b/packages/cli/test/onboard.test.ts @@ -0,0 +1,205 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const execFileAsync = promisify(execFile); +const binPath = resolve(import.meta.dirname, "../dist/index.js"); + +interface ExecError extends Error { + stdout?: string; + stderr?: string; + code?: number; +} + +async function runCli( + args: string[], + cwd: string +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + try { + const { stdout, stderr } = await execFileAsync("node", [binPath, ...args], { + cwd, + }); + return { stdout, stderr, exitCode: 0 }; + } catch (error) { + const error_ = error as ExecError; + return { + stdout: error_.stdout ?? "", + stderr: error_.stderr ?? "", + exitCode: error_.code ?? 1, + }; + } +} + +async function readJsonManifest(cwd: string): Promise> { + const text = await readFile(join(cwd, ".taskless", "taskless.json"), "utf8"); + return JSON.parse(text) as Record; +} + +describe("taskless onboard", () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "taskless-onboard-")); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + it("bootstraps .taskless/ and prints the recipe on first run", async () => { + const { stdout, exitCode } = await runCli(["onboard", "-d", cwd], cwd); + + expect(exitCode).toBe(0); + expect(stdout).toContain("# Topic: onboard"); + expect(stdout).toContain("## Goal"); + + const manifest = await readJsonManifest(cwd); + expect(manifest.version).toBe(2); + // init/onboard alone should not record onboarded + const install = manifest.install as { onboarded?: boolean } | undefined; + expect(install?.onboarded).toBeUndefined(); + }); + + it("refuses to print the recipe when install.onboarded is true", async () => { + // Pre-populate the manifest with onboarded:true. + await mkdir(join(cwd, ".taskless"), { recursive: true }); + await writeFile( + join(cwd, ".taskless", "taskless.json"), + JSON.stringify({ version: 2, install: { onboarded: true } }), + "utf8" + ); + + const { stdout, exitCode } = await runCli(["onboard", "-d", cwd], cwd); + + expect(exitCode).toBe(0); + expect(stdout).toContain("already marked complete"); + expect(stdout).toContain("--force"); + expect(stdout).not.toContain("# Topic: onboard"); + }); + + it("treats install.onboarded:false as not-onboarded and prints the recipe", async () => { + // The 3-state semantics treat absent and false equivalently for gating + // purposes; only true gates the recipe behind --force. + await mkdir(join(cwd, ".taskless"), { recursive: true }); + await writeFile( + join(cwd, ".taskless", "taskless.json"), + JSON.stringify({ version: 2, install: { onboarded: false } }), + "utf8" + ); + + const { stdout, exitCode } = await runCli(["onboard", "-d", cwd], cwd); + + expect(exitCode).toBe(0); + expect(stdout).toContain("# Topic: onboard"); + expect(stdout).not.toContain("already marked complete"); + }); + + it("--force prints the recipe even when onboarded:true", async () => { + await mkdir(join(cwd, ".taskless"), { recursive: true }); + await writeFile( + join(cwd, ".taskless", "taskless.json"), + JSON.stringify({ version: 2, install: { onboarded: true } }), + "utf8" + ); + + const { stdout, exitCode } = await runCli( + ["onboard", "--force", "-d", cwd], + cwd + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("# Topic: onboard"); + }); + + it("--mark-complete writes onboarded:true and preserves other fields", async () => { + // Seed the manifest with an existing install and an unknown top-level + // field; --mark-complete must not clobber either. + await mkdir(join(cwd, ".taskless"), { recursive: true }); + await writeFile( + join(cwd, ".taskless", "taskless.json"), + JSON.stringify({ + version: 2, + install: { + installedAt: "2026-04-16T00:00:00.000Z", + cliVersion: "0.7.0", + targets: { ".claude": { skills: ["taskless"], commands: ["tskl"] } }, + }, + experimental: { keep: "me" }, + }), + "utf8" + ); + + const { stdout, exitCode } = await runCli( + ["onboard", "--mark-complete", "-d", cwd], + cwd + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Marked Taskless onboarding as complete"); + + const manifest = await readJsonManifest(cwd); + const install = manifest.install as { + onboarded?: boolean; + installedAt?: string; + cliVersion?: string; + targets?: Record; + }; + expect(install.onboarded).toBe(true); + expect(install.installedAt).toBe("2026-04-16T00:00:00.000Z"); + expect(install.cliVersion).toBe("0.7.0"); + expect(install.targets).toEqual({ + ".claude": { skills: ["taskless"], commands: ["tskl"] }, + }); + expect(manifest.experimental).toEqual({ keep: "me" }); + }); + + it("--mark-complete is idempotent", async () => { + const first = await runCli(["onboard", "--mark-complete", "-d", cwd], cwd); + expect(first.exitCode).toBe(0); + const afterFirst = await readFile( + join(cwd, ".taskless", "taskless.json"), + "utf8" + ); + + const second = await runCli(["onboard", "--mark-complete", "-d", cwd], cwd); + expect(second.exitCode).toBe(0); + const afterSecond = await readFile( + join(cwd, ".taskless", "taskless.json"), + "utf8" + ); + + expect(afterSecond).toBe(afterFirst); + }); + + it("rejects --force --mark-complete with exit 1 and a clear error", async () => { + const { stderr, exitCode } = await runCli( + ["onboard", "--force", "--mark-complete", "-d", cwd], + cwd + ); + + expect(exitCode).toBe(1); + expect(stderr).toContain("--force"); + expect(stderr).toContain("--mark-complete"); + }); + + it("`taskless help onboard` matches the recipe printed by `taskless onboard --force`", async () => { + // Pre-mark onboarded so the `onboard` path also prints the recipe via + // --force, ensuring we compare recipe-vs-recipe rather than gate-vs-recipe. + await mkdir(join(cwd, ".taskless"), { recursive: true }); + await writeFile( + join(cwd, ".taskless", "taskless.json"), + JSON.stringify({ version: 2, install: { onboarded: true } }), + "utf8" + ); + + const help = await runCli(["help", "onboard", "-d", cwd], cwd); + const onboard = await runCli(["onboard", "--force", "-d", cwd], cwd); + + expect(help.exitCode).toBe(0); + expect(onboard.exitCode).toBe(0); + expect(onboard.stdout.trim()).toBe(help.stdout.trim()); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0f83f9..1ae8490 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: posthog-node: specifier: ^5.28.11 version: 5.28.11 + sprintf-js: + specifier: ^1.1.3 + version: 1.1.3 yaml: specifier: ^2.8.2 version: 2.8.2 @@ -99,6 +102,9 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: + '@types/sprintf-js': + specifier: ^1.1.4 + version: 1.1.4 openapi-typescript: specifier: ^7.13.0 version: 7.13.0(typescript@5.9.3) @@ -795,6 +801,9 @@ packages: '@types/node@25.3.3': resolution: {integrity: sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==} + '@types/sprintf-js@1.1.4': + resolution: {integrity: sha512-aWK1reDYWxcjgcIIPmQi3u+OQDuYa9b+lr6eIsGWrekJ9vr1NSjr4Eab8oQ1iKuH1ltFHpXGyerAv1a3FMKxzQ==} + '@typescript-eslint/eslint-plugin@8.56.1': resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2025,6 +2034,9 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2991,6 +3003,8 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/sprintf-js@1.1.4': {} + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4190,6 +4204,8 @@ snapshots: sprintf-js@1.0.3: {} + sprintf-js@1.1.3: {} + stackback@0.0.2: {} std-env@3.10.0: {} diff --git a/skills/taskless/SKILL.md b/skills/taskless/SKILL.md index 179a6e7..9a9e8d5 100644 --- a/skills/taskless/SKILL.md +++ b/skills/taskless/SKILL.md @@ -12,9 +12,14 @@ description: | - "run taskless", "taskless check", "validate against taskless rules" - "taskless login/logout/status", "is taskless connected" - "add taskless to CI", "wire taskless into github actions" + - "onboard with taskless", "set up taskless for this project" - Do NOT trigger on generic ESLint, linting, or rule requests that don't - reference Taskless or .taskless/ files. + Also trigger when the user asks to add/write/create a rule and has NOT + named a specific lint/format/static-analysis tool. Examples that suppress + this trigger (illustrative — any named tool of this kind suppresses): + eslint, ruff, biome, ast-grep. On this trigger, surface a quiet + single-line offer rather than launching a recipe; see the body's quiet + suggestion section. metadata: author: taskless version: 0.7.0 @@ -41,6 +46,7 @@ confirm, offer to run `npx @taskless/cli` to install. Otherwise, stop. | -------------------------- | ------------------------------------- | | First-time install | tell user to run `npx @taskless/cli` | | Update existing install | `npx @taskless/cli update` | +| Discover candidate rules | `npx @taskless/cli help onboard` | | Create a new rule | `npx @taskless/cli help rule create` | | Improve an existing rule | `npx @taskless/cli help rule improve` | | Delete a rule | `npx @taskless/cli help rule delete` | @@ -52,6 +58,19 @@ If the user's intent is ambiguous between two topics, run `npx @taskless/cli help` (no args) to see the disambiguation table, or ask the user. +## Quiet suggestion + +When you trigger because the user asked to add/write/create a rule and did +NOT name a specific tool (per the description's named-tool clause), DO NOT +launch into a full recipe. Instead surface a one-line offer such as: "I can +capture this as a Taskless rule if you want — say so, or I'll proceed with +." If the user accepts, fetch +`npx @taskless/cli help rule create` and follow it. If the user declines or +ignores the offer, proceed with what you would have done without this skill, +and DO NOT re-offer Taskless in the same conversation. The decline is sticky +within the conversation only — do NOT write any persistent decline state to +disk or to `.taskless/taskless.json`. + ## --anonymous Any rule/check command accepts `--anonymous` to skip the Taskless API and