Skip to content

The library

waypost is a terminal UI and a web view built on top of one library: @sksizer/detect-runners. Everything on this page belongs to that library — it has no dependency on either UI, and everything either UI shows comes from calling it.

function collect(root: string, options?: CollectOptions): Promise<Catalog>

Collection is a function, not a build step or a cached snapshot: every call re-probes the directory. collect() itself holds nothing across calls, so a catalog cannot silently go stale after someone adds a task — storing an answer is a separate layer a caller opts into, described under The cache below. Providers run concurrently — probing moon costs roughly 700ms and probing just costs roughly 5ms, and run serially those add up, while in parallel the slowest one sets the pace.

CollectOptions:

interface CollectOptions {
providers?: RunnerProvider[] // defaults to builtinProviders
only?: string[] // restrict to these provider ids
exclude?: string[] // skip these provider ids
allowExec?: boolean // defaults to false
discovery?: 'git' | 'walk' | 'auto' // defaults to "auto"
execWithheldHint?: string // appended to the diagnostic on a withheld runner
excludePaths?: string[] // paths to leave out of discovery: a bare name
// matches any segment, a value with a slash
// matches a path from the root
makeRunner?: (binary: string) => AsyncCommandRunner // defaults to the real subprocess seam
which?: (binary: string) => string | null // defaults to the real PATH lookup
probeObserver?: (probe: ProbeRecord) => void // what each provider's run measured and touched
signal?: AbortSignal
}

discover(root, options?) is the cheaper sibling: it returns just the ids of runners that are present, without enumerating their tasks.

allowExec defaults to false. This is the single most important thing to understand before pointing the library at a directory you did not write.

Some runners can only be read by asking them to describe themselves. moon query tasks runs the target repository’s own toolchain configuration — that is arbitrary code, chosen by whoever wrote that repo, not by you. mise merges task definitions across several config files and can execute [hooks] while doing it. Reading those runners is not “parsing a file” — it is running the target repository’s build tooling.

So every provider declares which kind it is:

type ProviderKind = 'parse' | 'exec'

parse is a floor, not a promise never to spawn. A parse provider always has an answer from files alone, so it is safe to run against any directory, including one you have never seen before — and collect(root, { allowExec: false }), the default, runs only parse providers. An exec provider cannot answer at all without spawning, so it is withheld entirely, reported rather than silently dropped:

{
runner: "moon",
status: "absent",
skippedForExec: true,
diagnostics: [{ severity: "info", message: "moon needs to run a subprocess…" }],
}

just is kind: "parse" despite being able to shell out: it has two paths — just --dump when the binary is available and execution is allowed, and a source scan of the justfile otherwise. The fallback finds the same recipes; it just cannot see docs, parameter types, or private flags, and says so in diagnostics.

collect() is deliberately trust-agnostic. It never reads the trust store and has no idea one exists: allowExec is a plain boolean the caller already resolved, so a programmatic consumer passing false is never affected by a trust decision it did not ask about. The CLIs resolve the store above it, combining the configured value, the flag and the per-repository record into that one boolean — see Trust & execution. execWithheldHint is the other half of that seam: the CLI hands down the sentence it wants a user to read on a withheld runner, because the library cannot write it.

Checking whether a tool is installed is not execution. ctx.which() is a PATH lookup with no subprocess, so it is available and safe under allowExec: false.

The library ships a set of built-in providers. None has a privilege your own provider would lack — see Extending.

| id | detects | kind | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | moon | .moon/workspace.yml; reads via moon query tasks / projects | exec | | mise | mise.toml, mise-tasks/; reads via mise tasks --json | exec | | just | justfile; reads via just --dump when exec is allowed, else a source scan | parse | | node-scripts | package.json scripts, verb derived from the lockfile | parse | | make | Makefile targets at column 0 | parse | | cargo | Cargo.toml; synthesizes the conventional cargo verbs | parse | | sbt | build.sbt, project/build.properties; reads via sbt projects tasks | exec | | python | pyproject.toml’s configured [tool.*] sections, and [project.scripts] / [tool.poetry.scripts] routed by the lockfile’s manager | parse | | composer | composer.json scripts | parse | | deno-tasks | deno.json / deno.jsonc tasks | parse | | turbo | turbo.json’s v2 tasks, with a v1 pipeline fallback | parse | | maven | pom.xml; synthesizes the conventional lifecycle verbs | parse | | swiftpm | Package.swift; synthesizes the conventional swift build/test/run verbs | parse | | gradle | build.gradle(.kts), settings.gradle(.kts); reads via gradle tasks --all when exec is allowed, else synthesizes the lifecycle verbs; uses ./gradlew when a wrapper is present | parse | | go-task | Taskfile.yml and its variants; reads via task --list-all --json when exec is allowed, else a narrow reader over the tasks: map | parse | | rake | Rakefile; reads via rake -AT when exec is allowed, else a scan of the Ruby source | parse | | docker-compose | compose.yaml and its three siblings, one winner per directory; the services: map read by a narrow hand-written YAML reader. Tasks are executes: "container" | parse | | github-actions | .github/workflows/*.yml at the repository root; only workflows declaring a workflow_dispatch trigger become tasks, because only those can be dispatched. Tasks are executes: "remote" | parse |

builtinProviders is exported in this order and is also the order a catalog’s reports appear in, before any runners.order configuration reorders them.

Some runners are slow to ask: moon starts its toolchain, sbt starts a JVM, mise reads and merges a config tree. The library ships a store above collect() so a surface can paint something at once and refresh behind it. It is a LAYER a caller opts into, never something the library does behind a caller’s back — a programmatic consumer that asked for a probe gets a probe.

import { cacheDir, collectAndCache, readCachedCatalog } from '@sksizer/detect-runners'
const store = cacheDir()
// Paints now: hashes a few files, probes nothing.
const stored = await readCachedCatalog(root, options, store)
// Probes for real and stores what came back.
const fresh = await collectAndCache(root, options, store)

readCachedCatalog returns { catalog, runners, allFresh }. runners carries one verdict per selected provider — { runner, state, reason?, ageMs?, estimateMs, slow }, where state is "fresh", "stale" or "miss". Stale rows are kept and labelled rather than dropped: an answer that was true an hour ago beats an empty screen, as long as the screen says which it is. The function never throws — an unreadable store, an unlistable root, a fingerprint that cannot be computed all degrade to a miss, which is the state a caller would have been in with no cache at all.

collectAndCache returns collect()’s catalog untouched but for one truncation diagnostic (below). Writing is best-effort: a read-only home or a full disk costs the next run some time and never fails the collection.

estimateMs is the MEDIAN of a rolling window of the last five probe durations measured in this repository, or null when nothing has been measured. Null is not zero, and a consumer must not print a cost it does not have. slow is that measurement crossing a threshold — measured, never declared, so a runner that is quick in your repository is not labelled slow because it is slow in someone else’s. There is no slow field on the provider contract.

$XDG_CACHE_HOME/waypost/, or ~/.cache/waypost/. One file per (root, provider), written to a temp file and renamed, and format-versioned both in the path and inside each file.

XDG cache, deliberately not the ~/.local/state/waypost/ the trust store uses. Losing this directory costs time and nothing else: a user who clears their caches pays one slow probe. Losing the trust store would silently un-trust repositories, and a consent record a cache sweep can erase is not a consent record. Everything here is reconstructible by asking again; nothing in the trust store is.

The cache is no part of the execution gate. A cached answer is never a trust grant, clearing the cache never changes what is trusted, and neither an errored report nor one carrying skippedForExec is ever stored — writeEntry refuses both, so an untrusted run cannot poison the answer a later trusted run should give. Under allowExec: false a cached read reports each exec provider as fresh and carries the same withheld report collect() would have produced, from the same helper. Trust is resolved above collect() by @sksizer/sdlc-tool’s policyExecGate, which never reads the cache — see Trust & execution.

Invalidation is a content hash, never an mtime

Section titled “Invalidation is a content hash, never an mtime”

A stored entry carries a digest over the bytes of the files it depends on. touching a justfile does not invalidate it, and neither does a rewrite to identical bytes — which is what a formatter, a git checkout of the same content, or a rebuilt generated file does all day.

For a parse provider the coverage is close to exact, and free: ProviderContext mediates every read a provider makes, so recording what it touched records precisely what it read.

Reads alone would not be enough. A provider that discovers by listing — every .github/workflows/*.yml, every compose.yaml below the root — depends on the answer to its query, not only on the files that answer happened to name. A file added later matching the same pattern was never read, so it moves no recorded hash. The patterns a provider passes to ctx.files() are therefore recorded beside the paths and re-expanded on every read: watch patterns it never had to declare.

For an exec provider it is deliberately incomplete, and worth stating plainly. Such a provider reads only its markers, while its TOOL merges files the provider never opens — moon’s .moon/tasks/** and each project’s own moon.yml, mise’s nested mise.toml files, sbt’s project/**. RunnerProvider.watch closes the part a basename can express, and exec entries additionally expire after four hours as a backstop for what a fingerprint cannot see.

Even then it is a good signal, not a proof. A task set can change with no watched file changing at all: a plugin version resolving differently, a global ~/.config/mise/config.toml, an environment variable a build script reads. Every consumer of a cached answer must tolerate a stale hit.

One more limit: a fingerprint hashes at most 256 paths. A run that hits that cap adds an info diagnostic to the runner’s report rather than letting the entry look fresher than it is.

import { humanAge, humanMs } from '@sksizer/detect-runners/duration'

The two formatters every surface reporting on the cache needs: humanMs(1423) is "1.4s", and humanAge(35_000) is "35s old" while humanAge(undefined) is "of unknown age" — a miss has no age, and reading as "0s old" would claim a freshness nothing measured. Both are also re-exported from the package entry; the subpath exists so a browser bundle can take them without dragging in node:fs, exactly as ./match does.

Only the number’s spelling is shared. The sentence around it is each surface’s own: the CLIs say 35s old, refresh usually about 149ms, the terminal UI says ~149ms, the page says never timed here.

import { exact, fuzzy, matchEntry, terms } from '@sksizer/detect-runners/match'

Ranked search over catalog entries — the one matcher the terminal UI and the web view both use, so the same keystrokes rank the same way regardless of which one you’re in. It has its own export subpath because the web view loads it transpiled for the browser, and it is deliberately kept free of Node builtins and DOM APIs so that build doesn’t break.

The query language: terms are whitespace-separated and ANDed, every term must match some field; a leading ' forces that term to match as a literal substring rather than a fuzzy subsequence; mode: "exact" forces every term that way; matching is case-insensitive throughout.

matchEntry(entry, terms, mode) scores one Matchable (a structural subset of Task: path, display, doc, detail, tags) against a query, returning null if any term misses, or a score plus the offsets that matched, so a caller can highlight them.