Skip to content
Merged
38 changes: 38 additions & 0 deletions .changeset/onboard-host-tool-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
"@taskless/cli": patch
---

`taskless info --json` renames its `tools` key to `harnesses`, and gives
`tools` to the command-line binaries found on `PATH`.

**This is the consumer-visible part.** The array that lists Claude Code,
Codex, Cursor and OpenCode, with each one's installed skills and their
staleness, is unchanged in shape — it now lives under `harnesses`. Anything
reading `.tools[].skills` from `info --json` reads `.harnesses[].skills`
instead. The new `tools` array carries `{ name, present, applicable, path? }`
for `gh`, `git` and `jq`.

`present` is established by looking for a file of that name on `PATH`.
Taskless does not spawn a detected binary, does not read its version, and does
not hash it, so `present: true` is presence and not a working install. A
sha256-against-published-releases tier was considered and dropped on
measurement: GitHub publishes digests for `gh`'s release archives and
installers rather than for the extracted binary, and a Homebrew-installed `gh`
2.97.0 matched 0 of the 21 official digests — a tier that reports "unverified"
for the ordinary macOS install path is worse than no tier at all.

`applicable` is the separate question of whether a tool could accomplish
anything where it is being asked to. `gh` in a repository with no GitHub
`origin` is present and inapplicable, and reporting that as "missing" would
produce the one instruction that cannot help — "install `gh`".

The `onboard` recipe (topic v4) uses both. Its source menu now states what was
found rather than telling the agent to run `command -v gh`, says in one line
why a source is not offered instead of dropping it silently, and no longer
names Linear as the expected issue tracker: a bug-tracker scan is offered when
the agent has an MCP that reaches one, with Jira and Linear as examples of the
class. `@taskless/cli/prompts` is unaffected — with no host state supplied the
recipe renders its full menu, unchanged.

`patch` rather than `minor`: the package is `0.y.z`, where semver puts added
surface outside the stability guarantee.
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
## Context

Two constraints shape every decision below.

**The prompts module must stay Worker-safe.** `packages/cli/src/prompts/`
is published as `@taskless/cli/prompts` and imported by Workers without
`nodejs_compat`, where a module-scope `process` read throws at import
time. `assert-library-graphs` in `vite.config.ts` fails the build if the
entry's chunk graph reaches a node builtin or the CLI entry. So the
prompts module cannot detect anything: it receives a value.

**Two serving paths must agree byte for byte.** `taskless onboard
--force` and `taskless agent onboard` print the same recipe, asserted in
`packages/cli/test/onboard.test.ts`, including under an npx-shaped
environment. Any state one computes, the other must compute identically.

## Goals / Non-Goals

**Goals.** Answer "can this flow mine PR comments?" once, in the CLI,
from evidence the CLI already has. Say why a source is missing when it
is missing. Build the substitution as a general mechanism.

**Non-Goals.** Verifying a binary. Detecting MCP servers. Adding a
subcommand.

## Decisions

### Presence, via `findOnPath`, and nothing else

`findOnPath(command)` in `packages/cli/src/rules/platform-binary.ts`
walks `PATH` and `existsSync`es a candidate. It executes nothing, which
is the whole property being bought: a detection pass that spawns
arbitrary binaries found on a user's `PATH` is a different and much
larger promise than the one this flow needs.

The recipe therefore states presence and never verification. The phrasing
is load-bearing: "`gh` is on your PATH; Taskless did not run it" is an
honest report of exactly what was measured, where "`gh` is available"
would be a claim about a working install that was never checked.

**Rejected: a hash tier.** Comparing the on-disk binary's sha256 against
published release digests. Measured and abandoned: GitHub publishes
checksums for `gh`'s release archives and installers, not for the
extracted binary, and the local Homebrew `gh` 2.97.0 matched 0 of 21
official digests. The tier would report "unverified" for the most common
install path on macOS, which a reader reads as "suspicious" rather than
"not checkable".

### `applicable` is a separate axis from `present`

A tool entry carries both. `present` is "a file of this name is on
`PATH`". `applicable` is "this tool could do anything useful here".

They are separate because they fail for unrelated reasons and the reader
needs different sentences. `gh` is not applicable when
`resolveRepositoryContext(cwd).ghOwner` is `UNKNOWN_GH_OWNER` — the repo
has no GitHub `origin`, so there are no pull requests to mine no matter
what is installed.

**Precedence: `not-applicable` outranks `absent`.** A GitLab repository
with `gh` installed must not be offered PR-comment mining, and must not
be told to install `gh`. Collapsing the two into one boolean would
produce exactly that wrong instruction.

Reusing `resolveRepositoryContext` rather than inventing a second
"is this GitHub" signal is deliberate: it is already the single
resolution behind `info` and telemetry, and a second one could disagree
with it.

### The variable is the whole block, connective included

`recipes.ts` already carries this pattern for `DETECT_EVIDENCE` and
`LOGIN_EVIDENCE`, with the reasoning written at the definition: the
prose around a substitution depends on it grammatically, so replacing
only the command leaves a dangling clause. Post-stripping text from a
rendered recipe has the same defect one layer later, and additionally
cannot promise the default rendering is unchanged.

So `%(SOURCE_PR_REVIEW)s` is an entire menu bullet — its own `-`, its
own bolded label, its own sentences — and `%(HOST_TOOLS)s` is an entire
numbered step including its title. In every state the surrounding
markdown is valid without the renderer knowing anything about markdown.

### Default is the full menu

With no `hostTools` the renderer emits the recipe's unconditioned text:
every source offered, no claim about what is installed. This is what
`@taskless/cli/prompts` gets, and it is the right answer there — a
Worker consumer has no `PATH` to speak of, and a recipe that dropped
sources because the _host_ lacked `gh` would be describing the wrong
machine.

### Where detection runs

At the same point `invocation` is detected, in both `commands/onboard.ts`
and `commands/agent.ts`. `agent` computes it only when the requested
topic's template actually contains a host-tool variable, asked of
`getRawRecipe(...).variables` rather than by hardcoding "onboard" — so
the next recipe to use the mechanism needs no change here, and topics
that do not use it pay no `git` spawn.

### `tools` means CLI binaries; harnesses are `harnesses`

`info --json`'s `tools` key has always carried agent harnesses. The
noun was wrong before this change and is unusable now, so it becomes
`harnesses` and `tools` carries what the word says. Consumers inside
this repository are the schema, the command, the `info` recipe's example
payload, and `cli.test.ts`; all move together in this change.

## Risks / Trade-offs

**A consumer outside this repository reading `tools`.** The key is
published in `info --json` output. Mitigation is the release note, not a
compatibility shim: pre-1.0, and an alias would make the wrong noun
permanent.

**`agent` gains a `git` spawn on the topics that use the variable.**
Bounded to those topics by the `variables` check above, and
`resolveRepositoryContext` never throws.

## Migration Plan

None required. The manifest is untouched; nothing persists detection.
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
## Why

The `onboard` recipe tells an agent to probe for tooling the CLI has
already answered for, and assumes one specific issue tracker.

Three defects, all in `packages/cli/src/agent/onboard.md`:

1. **It assumes `gh`, in prose only.** Step 3 offers PR-review mining
"only if the `gh` CLI is available. Probe with `command -v gh`", and
step 4 repeats the probe. Nothing in `src/**/*.ts` invokes or probes
`gh`; the instruction spends an agent turn on a question the CLI can
answer for free, and answers it worse — `command -v gh` says nothing
about whether the repository has pull requests to mine at all.
2. **It names Linear.** Step 3's issue-tracker bullet reads "(Linear,
Jira, GitHub issues via `gh issue list`, etc.)". A menu that names one
vendor first reads as a recommendation. The CLI cannot see the agent's
MCP roster, so which tracker is reachable is the agent's judgement and
the recipe should stop pre-empting it.
3. **A non-GitHub repository is offered PR mining anyway.** `gh` on
`PATH` is not the same question as "this repository has pull
requests". A repo on GitLab with `gh` installed gets offered a scan
that cannot return anything.

Separately, `info --json`'s `tools` key means _agent harnesses_ (Claude
Code, Codex, Cursor, OpenCode). That is the wrong noun for the payload
and it occupies the name the new detection wants.

## What Changes

- **New leaf module `packages/cli/src/detect/host-tools.ts`.** It reports
presence of `gh`, `git` and `jq` by asking whether a file of that name
sits on `PATH` (`findOnPath`, which `existsSync`es and executes
nothing), and marks `gh` **not applicable** when
`resolveRepositoryContext(cwd).ghOwner` is the `[unknown]` sentinel.
- **`info --json` renames `tools` to `harnesses`.** The array is
unchanged; only the key moves. `tools` is then re-introduced carrying
the detected CLI binaries. This is the consumer-visible part of the
change.
- **A general recipe variable mechanism.** `RecipeOptions.hostTools`
carries the detected state into the renderer, which substitutes whole
blocks — including their grammatical connectives — exactly as
`DETECT_EVIDENCE` and `LOGIN_EVIDENCE` already do. `onboard` is the
only consumer in this change; the mechanism is not onboard-specific.
- **`onboard.md` step 3 and step 4 rewritten.** The source menu states
presence and never verification ("`gh` is on your PATH; Taskless did
not run it"), the issue-tracker bullet names Jira and Linear as
examples of a class rather than a default, and step 4 reports what was
found instead of telling the agent to re-probe.
- **An omitted source says why it is omitted, in one line.** Mirroring
`route.md`'s reasoning for the dropped remote tier: a reader who is not
told reads the omission as an oversight and asks for it, which costs a
turn. Precedence is `not-applicable` over `absent`: a non-GitHub
repository is told there are no pull requests to mine, not that `gh` is
missing, and is not offered PR-comment mining even with `gh` installed.
- **`@taskless/cli/prompts` is unaffected by default.** With no
`hostTools` supplied the renderer emits the recipe's full menu, so a
Worker consumer with no `PATH` keeps a complete recipe.

### Explicitly not built: verification

Presence only. Taskless never spawns a detected binary and never hashes
it. A sha256-against-known-releases tier was considered and measured as
intractable: GitHub publishes checksums for `gh`'s release _archives and
installers_, not for the extracted binary, and the local Homebrew `gh`
2.97.0 matched **0 of 21** official digests. A verification tier that
cannot verify the common install path is worse than no tier, because its
"unverified" verdict would read as "suspicious" rather than "unknown".

Nothing here is **BREAKING** in the semver sense. The package is
`0.y.z`, where semver puts added surface outside the stability
guarantee, so the bump is `patch`. The `tools` → `harnesses` rename is
still the thing a release note must lead with.

## Non-goals

- No version, no `--version` call, no hash, no "is this really `gh`"
check of any kind.
- No MCP detection. The CLI cannot see the agent's MCP roster and will
not guess at it; whether a bug tracker is reachable stays a judgement
the agent makes at runtime.
- No new subcommand. `info` already reports capability state and is
already JSON.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `cli`: the `info --json` payload requirement, for the `tools` →
`harnesses` rename and the new `tools` array.
- `cli-onboard`: the recipe-content requirement (source menu and tool
probing), and the `onboard.txt` → `onboard.md` filename drift left
behind by the Vale-coverage change.
- `cli-agent`: the sprintf named-argument requirement, for the host-tool
variables, and the same `.txt` drift in one scenario.
- `cli-knowledge-prompts`: one new requirement for the `hostTools`
option and its full-menu default.

## Impact

- `packages/cli/src/detect/host-tools.ts` (new): detection.
- `packages/cli/src/schemas/info.ts`: `harnesses`, plus the new `tools`.
- `packages/cli/src/commands/info.ts`: populate both; human output.
- `packages/cli/src/prompts/recipes.ts`: `hostTools` option, the
whole-block variables.
- `packages/cli/src/agent/onboard.md`: steps 3 and 4; topic v3 → v4.
- `packages/cli/src/commands/onboard.ts`, `packages/cli/src/commands/agent.ts`:
detect and pass, at the point `invocation` is already detected. Both
serving paths must compute the same state or byte-parity breaks.
- `packages/cli/src/agent/info.md`: the example payload.
- `packages/cli/test/`: `host-tools.test.ts` (new), plus updates to
`cli.test.ts` for the renamed key.

## Delivery shape

**Single PR.** Detection, the rename, the recipe rewrite, the spec
deltas and the tests are one reviewable diff and are only correct
together: renaming `tools` without adding its replacement, or rewriting
the recipe without the detection behind it, would each ship a broken
intermediate state.
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
## MODIFIED 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 three 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 `<package-manager-dlx>`). 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.
3. **Conditional blocks** — keys whose value is one of a fixed set of whole passages, selected by state the caller passes in. A conditional block SHALL span an entire syntactic unit of the surrounding markdown — a whole list item, a whole numbered step — and SHALL include the grammatical connective the neighbouring prose continues from. The renderer SHALL NOT remove or rewrite text after rendering to achieve a conditional effect: a post-render strip cannot promise that the unconditioned rendering is byte-for-byte what it was, and it leaves the prose depending on a clause that is no longer present. Every conditional block SHALL have a default passage used when the caller supplies no state, and that default SHALL be the unconditioned text a consumer with no host — the `@taskless/cli/prompts` export among them — receives.

The renderer SHALL additionally provide `TASKLESS_CLI` for every render. It is a hybrid of the first two flavors: system-resolved when the caller or the build knows the answer, and an agent-fill marker when neither does. It SHALL resolve in this order:

1. The caller-supplied invocation, when one is given.
2. The build-target invocation, when the build target is not prod — a `nightly`, `dev`, or `self` build knows exactly what it is and SHALL name itself.
3. Otherwise the agent-fill marker `<taskless-cli>`.

Step 3 SHALL NOT fall back to `npx @taskless/cli`. A prod build that does not know how it was launched has no basis for naming one launcher over another, and a marker asks the reading agent for the answer instead of asserting a wrong one.

No flavor SHALL read ambient host state. The render path is imported by Workers without `nodejs_compat`, so a value that can only be learned from `process`, the environment, `argv`, the filesystem, or `PATH` SHALL be detected in the CLI and passed in as a render option.

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 `<package-manager-dlx>` at every occurrence

#### Scenario: A conditional block renders its default with no caller state

- **WHEN** a recipe containing a conditional block is rendered with no state supplied for it
- **THEN** the block SHALL render its default passage
- **AND** the surrounding markdown SHALL remain well-formed

#### Scenario: A conditional block replaces a whole unit

- **WHEN** a conditional block selects a passage other than its default
- **THEN** the replaced region SHALL be a whole list item or a whole numbered step, connective included
- **AND** no text SHALL be removed from the rendered output after substitution

#### Scenario: TASKLESS_CLI renders a caller-supplied invocation

- **WHEN** a recipe containing `%(TASKLESS_CLI)s` is rendered with an explicit invocation
- **THEN** every occurrence SHALL render as that invocation

#### Scenario: TASKLESS_CLI names a non-prod build target

- **WHEN** a recipe containing `%(TASKLESS_CLI)s` is rendered with no explicit invocation from a `nightly`, `dev`, or `self` build
- **THEN** every occurrence SHALL render as that build's own invocation, so a nightly names `@taskless/cli-nightly` at its published version rather than the released package

#### Scenario: TASKLESS_CLI falls back to an agent-fill marker

- **WHEN** a recipe containing `%(TASKLESS_CLI)s` is rendered from a prod build with no explicit invocation
- **THEN** every occurrence SHALL render as the literal token `<taskless-cli>`
- **AND** SHALL NOT render as `npx @taskless/cli` or any other guessed launcher

#### Scenario: No legacy placeholder syntax remains in recipes

- **WHEN** any `<topic>.md` file under `packages/cli/src/agent/` 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
Loading
Loading