diff --git a/CMakeLists.txt b/CMakeLists.txt index e36d596..d68f238 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.20) -project(xprobe VERSION 0.6.0 LANGUAGES C) +project(xprobe VERSION 0.6.1 LANGUAGES C) include(CTest) diff --git a/Cargo.lock b/Cargo.lock index e90bab7..09041fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -732,7 +732,7 @@ dependencies = [ [[package]] name = "xprobe-cli" -version = "0.6.0" +version = "0.6.1" dependencies = [ "clap", "serde", @@ -746,7 +746,7 @@ dependencies = [ [[package]] name = "xprobe-collector" -version = "0.6.0" +version = "0.6.1" dependencies = [ "libbpf-rs", "perf-event-open", @@ -756,7 +756,7 @@ dependencies = [ [[package]] name = "xprobe-core" -version = "0.6.0" +version = "0.6.1" dependencies = [ "cpp_demangle", "nix", @@ -768,7 +768,7 @@ dependencies = [ [[package]] name = "xprobe-correlator" -version = "0.6.0" +version = "0.6.1" dependencies = [ "regex", "serde_json", @@ -777,7 +777,7 @@ dependencies = [ [[package]] name = "xprobe-exporter" -version = "0.6.0" +version = "0.6.1" dependencies = [ "serde_json", "xprobe-protocol", @@ -785,7 +785,7 @@ dependencies = [ [[package]] name = "xprobe-protocol" -version = "0.6.0" +version = "0.6.1" dependencies = [ "schemars", "serde", diff --git a/Cargo.toml b/Cargo.toml index 1357027..f9bdb3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.6.0" +version = "0.6.1" edition = "2024" license = "Apache-2.0" repository = "https://github.com/itdevwu/xprobe" diff --git a/README.md b/README.md index 16f7a79..d9ebd8d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@

xprobe

-

AI-native heterogeneous profiling tool for CPU and GPU.

+

Find out why your CPU, Python, or CUDA process is slow — in two commands.

+

A bounded Linux profiler for AI coding agents and the humans working with them:
+ eBPF + perf + NVIDIA CUPTI, one CLI, strict JSON, no daemon.

CI status Latest release @@ -9,12 +11,38 @@

-`xprobe` is an AI harness for measuring latency between two observable events -in a process, on the CPU, NVIDIA GPU, or across both. Its bounded native -profiler combines sampled native/Python stacks, eBPF function, syscall, -tracepoint and CPython runtime evidence with NVIDIA CUPTI, an agent-friendly -CLI, strict JSON contracts, explicit correlation quality, and no daemon or -server lifecycle. +

+ + + xprobe triages a slow PyTorch CUDA process into ranked findings, then measures request to launch 1.14 ms, launch to kernel 99 µs, and kernel 1.2 µs on CPU and GPU timelines + +

+ +

Real numbers from the v0.6.0 release gate on an RTX 3060: a PyTorch CUDA workload and a request → launch → kernel path.

+ +## What it does + +Point xprobe at a running process. It watches one bounded window and tells you +where the time went — on-CPU hotspots, threads waiting for a CPU or blocked, +container CPU throttling, hot syscalls, GPU kernels, and GPU clock limits — +ranked by impact, each with a ready next measurement. The second command then +measures that exact latency: a function, a syscall, a CUDA API call, a kernel, +a copy, an NVTX range, or a whole request → launch → kernel path from a single +capture. + +- **Built for AI agents.** Ships as an [Agent Skill](skills/xprobe-measure-latency/SKILL.md) + for Claude Code, Codex, and Cursor. Compact JSON with a `good`/`caution` + quality verdict, so an agent can act on results without reading 100 KB of + events. +- **CPU, Python, and GPU in one tool.** Sampled native and CPython stacks, + eBPF uprobes, syscalls, tracepoints, CPython GC, and CUDA 12/13 activity via + CUPTI, correlated with explicit confidence. +- **Bounded by design.** Every capture is limited by samples or duration, + timeout, and capacity; there is no daemon; CUDA injection is disclosed + before it happens; the raw events are always kept for offline re-analysis. +- **Honest about what it knows.** Sampled shares are estimates, temporal + matches are labeled heuristic, and anything a capture cannot answer is listed + as not covered. ## Install @@ -23,7 +51,7 @@ only installation action required from the user: ```bash npx skills@1 add \ - https://github.com/itdevwu/xprobe/tree/v0.6.0/skills/xprobe-measure-latency \ + https://github.com/itdevwu/xprobe/tree/v0.6.1/skills/xprobe-measure-latency \ --global ``` diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 36cfa2e..feea27a 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -17,7 +17,7 @@ repairs the matching xprobe CLI itself: ```bash npx skills@1 add \ - https://github.com/itdevwu/xprobe/tree/v0.6.0/skills/xprobe-measure-latency \ + https://github.com/itdevwu/xprobe/tree/v0.6.1/skills/xprobe-measure-latency \ --global ``` diff --git a/docs/assets/social-preview.png b/docs/assets/social-preview.png new file mode 100644 index 0000000..b0092f5 Binary files /dev/null and b/docs/assets/social-preview.png differ diff --git a/docs/assets/social-preview.svg b/docs/assets/social-preview.svg new file mode 100644 index 0000000..e76538c --- /dev/null +++ b/docs/assets/social-preview.svg @@ -0,0 +1 @@ +xprobeWhy is my CPU, Python, or CUDA process slow? Two commands.xprobe: triage a slow process, then measure the exact latencyA terminal runs xprobe triage on a PyTorch CUDA process. Ranked findings show a CPU hotspot, a dominant GPU kernel, and power-capped GPU clocks. A second command measures request to launch 1.14 ms, launch to kernel 99 microseconds, and kernel duration 1.2 microseconds on CPU and GPU timelines.Why is PID 4242 slow?$xprobe measure --pid 4242 --triage --duration-ms 20001CPU_HOTSPOTlibgomp worker spinning: 77% of sampled stacks76%2GPU_KERNEL_DOMINANTampere_sgemm_128x64_nn: all kernel time, GPU busy 54%54%3GPU_CLOCK_LIMITEDsw_power_cap under loadn/a→ each finding ships a ready next spec$xprobe measure --spec next.jsonCPUGPUrequestcudaLaunchKernel returnskernelrequest → launch 1.14 ms (first-after)launch → kernel 99 µs (exact)kernel 1.2 µs (exact)Two commands. Bounded capture. No daemon. Evidence an agent can read. diff --git a/docs/assets/xprobe-flow-dark.svg b/docs/assets/xprobe-flow-dark.svg new file mode 100644 index 0000000..f7b5d22 --- /dev/null +++ b/docs/assets/xprobe-flow-dark.svg @@ -0,0 +1,34 @@ +xprobe: triage a slow process, then measure the exact latencyA terminal runs xprobe triage on a PyTorch CUDA process. Ranked findings show a CPU hotspot, a dominant GPU kernel, and power-capped GPU clocks. A second command measures request to launch 1.14 ms, launch to kernel 99 microseconds, and kernel duration 1.2 microseconds on CPU and GPU timelines.Why is PID 4242 slow?$xprobe measure --pid 4242 --triage --duration-ms 20001CPU_HOTSPOTlibgomp worker spinning: 77% of sampled stacks76%2GPU_KERNEL_DOMINANTampere_sgemm_128x64_nn: all kernel time, GPU busy 54%54%3GPU_CLOCK_LIMITEDsw_power_cap under loadn/a→ each finding ships a ready next spec$xprobe measure --spec next.jsonCPUGPUrequestcudaLaunchKernel returnskernelrequest → launch 1.14 ms (first-after)launch → kernel 99 µs (exact)kernel 1.2 µs (exact)Two commands. Bounded capture. No daemon. Evidence an agent can read. diff --git a/docs/assets/xprobe-flow-light.svg b/docs/assets/xprobe-flow-light.svg new file mode 100644 index 0000000..ea0aabc --- /dev/null +++ b/docs/assets/xprobe-flow-light.svg @@ -0,0 +1,34 @@ +xprobe: triage a slow process, then measure the exact latencyA terminal runs xprobe triage on a PyTorch CUDA process. Ranked findings show a CPU hotspot, a dominant GPU kernel, and power-capped GPU clocks. A second command measures request to launch 1.14 ms, launch to kernel 99 microseconds, and kernel duration 1.2 microseconds on CPU and GPU timelines.Why is PID 4242 slow?$xprobe measure --pid 4242 --triage --duration-ms 20001CPU_HOTSPOTlibgomp worker spinning: 77% of sampled stacks76%2GPU_KERNEL_DOMINANTampere_sgemm_128x64_nn: all kernel time, GPU busy 54%54%3GPU_CLOCK_LIMITEDsw_power_cap under loadn/a→ each finding ships a ready next spec$xprobe measure --spec next.jsonCPUGPUrequestcudaLaunchKernel returnskernelrequest → launch 1.14 ms (first-after)launch → kernel 99 µs (exact)kernel 1.2 µs (exact)Two commands. Bounded capture. No daemon. Evidence an agent can read. diff --git a/docs/installation.md b/docs/installation.md index a98ea9c..f1ea2a1 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -13,7 +13,7 @@ user needs to run: ```bash npx skills@1 add \ - https://github.com/itdevwu/xprobe/tree/v0.6.0/skills/xprobe-measure-latency \ + https://github.com/itdevwu/xprobe/tree/v0.6.1/skills/xprobe-measure-latency \ --global ``` @@ -29,7 +29,7 @@ The versioned bootstrap installs to `~/.local` without root access: ```bash curl --proto '=https' --tlsv1.2 -fsSL \ - https://raw.githubusercontent.com/itdevwu/xprobe/v0.6.0/install.sh | sh + https://raw.githubusercontent.com/itdevwu/xprobe/v0.6.1/install.sh | sh ``` The bootstrap downloads the release archive and its SHA256 file, verifies the @@ -48,7 +48,7 @@ prefix, download the script and pass `--prefix`: ```bash curl --proto '=https' --tlsv1.2 -fsSLO \ - https://raw.githubusercontent.com/itdevwu/xprobe/v0.6.0/install.sh + https://raw.githubusercontent.com/itdevwu/xprobe/v0.6.1/install.sh sh install.sh --prefix /opt/xprobe ``` @@ -60,7 +60,7 @@ script with `sudo`. The installer never elevates privileges itself. For a fully explicit archive workflow: ```bash -version=0.6.0 +version=0.6.1 base=https://github.com/itdevwu/xprobe/releases/download/v$version archive=xprobe-$version-linux-x86_64.tar.gz @@ -106,7 +106,7 @@ missing or damaged, manually install the complete version-matched directory: ```bash npx skills@1 add \ - https://github.com/itdevwu/xprobe/tree/v0.6.0/skills/xprobe-measure-latency \ + https://github.com/itdevwu/xprobe/tree/v0.6.1/skills/xprobe-measure-latency \ --global ``` @@ -115,7 +115,7 @@ installation names the target explicitly: ```bash npx --yes skills@1 add \ - https://github.com/itdevwu/xprobe/tree/v0.6.0/skills/xprobe-measure-latency \ + https://github.com/itdevwu/xprobe/tree/v0.6.1/skills/xprobe-measure-latency \ --agent codex --global --copy --yes ``` diff --git a/install.sh b/install.sh index d9e8ecb..46eb4ec 100755 --- a/install.sh +++ b/install.sh @@ -2,7 +2,7 @@ set -eu repository=${XPROBE_REPOSITORY:-itdevwu/xprobe} -version=${XPROBE_VERSION:-0.6.0} +version=${XPROBE_VERSION:-0.6.1} if [ -n "${XPROBE_PREFIX:-}" ]; then prefix=$XPROBE_PREFIX elif [ -n "${HOME:-}" ]; then @@ -19,7 +19,7 @@ Install a released xprobe binary and its CUDA Agents. Usage: install.sh [--version VERSION] [--prefix DIR] [--uninstall] Options: - --version VERSION Release to install (default: 0.6.0) + --version VERSION Release to install (default: 0.6.1) --prefix DIR Installation prefix (default: $HOME/.local) --uninstall Remove xprobe from the selected prefix -h, --help Show this help diff --git a/scripts/render-readme-art.py b/scripts/render-readme-art.py new file mode 100755 index 0000000..a93b7a7 --- /dev/null +++ b/scripts/render-readme-art.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Render the README hero animation as light and dark SVGs. + +The numbers come from the v0.6.0 live release gate on an RTX 3060: the PyTorch +CUDA triage fixture and the multisource request/launch/kernel graph. Rerun +after changing the story: + + scripts/render-readme-art.py docs/assets +""" + +import pathlib +import sys +from html import escape + +WIDTH, HEIGHT = 960, 540 +CYCLE_S = 14.0 +FONT = "ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" +SANS = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" + +THEMES = { + "light": { + "bg": "#ffffff", + "panel": "#f6f8fa", + "border": "#d0d7de", + "text": "#1f2328", + "muted": "#59636e", + "prompt": "#0969da", + "cpu": "#8250df", + "gpu": "#1a7f37", + "env": "#9a6700", + "track": "#eaeef2", + }, + "dark": { + "bg": "#0d1117", + "panel": "#161b22", + "border": "#30363d", + "text": "#e6edf3", + "muted": "#8d96a0", + "prompt": "#4493f8", + "cpu": "#ab7df8", + "gpu": "#3fb950", + "env": "#d29922", + "track": "#21262d", + }, +} + +# (start, end) as fractions of the cycle for each stage. +STAGES = { + "cmd1": (0.02, 0.10), + "find1": (0.12, 0.16), + "find2": (0.16, 0.20), + "find3": (0.20, 0.24), + "next": (0.27, 0.31), + "cmd2": (0.34, 0.42), + "lanes": (0.44, 0.48), + "edge1": (0.50, 0.55), + "edge2": (0.56, 0.61), + "edge3": (0.62, 0.67), + "tagline": (0.72, 0.77), +} +HOLD_UNTIL = 0.93 + + +def keyframes(name: str, start: float, end: float, kind: str) -> str: + """Hidden until start, shown from end to the loop's end. + + The whole scene fades out and back in around the loop boundary, so + individual elements never need their own exit frames. + """ + s, e = (round(v * 100, 2) for v in (start, end)) + if kind == "type": + hidden, shown = "transform: scaleX(1)", "transform: scaleX(0)" + elif kind == "grow": + hidden, shown = "transform: scaleX(0)", "transform: scaleX(1)" + else: + hidden = "opacity: 0; transform: translateY(6px)" + shown = "opacity: 1; transform: translateY(0)" + return f"@keyframes {name} {{ 0%, {s}% {{ {hidden} }} {e}%, 100% {{ {shown} }} }}" + + +def scene_keyframes() -> str: + h = round(HOLD_UNTIL * 100, 2) + return ( + f"@keyframes scene {{ 0% {{ opacity: 0 }} 1.5%, {h}% {{ opacity: 1 }} " + f"99%, 100% {{ opacity: 0 }} }}" + ) + + +def style(theme: dict) -> str: + rules = [ + f".mono {{ font-family: {FONT}; }}", + f".sans {{ font-family: {SANS}; }}", + ".anim { transform-box: fill-box; }", + scene_keyframes(), + f".scene {{ animation: scene {CYCLE_S}s linear infinite; }}", + ] + for name, (start, end) in STAGES.items(): + kind = "type" if name.startswith("cmd") else "fade" + rules.append(keyframes(f"k-{name}", start, end, kind)) + rules.append( + f".{name} {{ animation: k-{name} {CYCLE_S}s ease-out infinite; " + f"{'transform-origin: right center;' if kind == 'type' else ''} }}" + ) + for name in ("find1", "find2", "find3"): + start, end = STAGES[name] + rules.append(keyframes(f"g-{name}", end, end + 0.06, "grow")) + rules.append( + f".bar-{name} {{ animation: g-{name} {CYCLE_S}s ease-out infinite; " + "transform-origin: left center; }" + ) + rules.append( + "@media (prefers-reduced-motion: reduce) { * { animation: none !important; } " + ".cover { display: none; } }" + ) + return "\n".join(rules) + + +def text(x, y, content, size, fill, cls="mono", weight="400", anchor="start") -> str: + return ( + f'{escape(content)}' + ) + + +def command(y: int, line: str, stage: str, t: dict) -> str: + """A typed command: the text is revealed by a cover that shrinks away.""" + width = 22 + len(line) * 8.4 + return ( + f'{text(48, y, "$", 15, t["prompt"], weight="700")}' + f'{text(66, y, line, 15, t["text"])}' + f'' + ) + + +def finding(y: int, rank: int, code: str, detail: str, share: float | None, + color: str, stage: str, t: dict) -> str: + bar_x, bar_w = 600, 260 + parts = [ + f'', + text(48, y, f"{rank}", 14, t["muted"]), + text(70, y, code, 14, color, weight="700"), + text(70, y + 19, detail, 12.5, t["muted"]), + f'', + ] + if share is None: + parts.append(text(bar_x + bar_w + 14, y, "n/a", 13, t["muted"])) + else: + parts.append( + f'' + ) + parts.append(text(bar_x + bar_w + 14, y, f"{share:.0%}", 13, t["text"], weight="700")) + parts.append("") + return "".join(parts) + + +def timeline(t: dict) -> str: + lane_x0, lane_x1 = 150, 900 + cpu_y, gpu_y = 362, 440 + req, launch, kstart, kend = 190, 560, 690, 760 + parts = [ + '', + text(48, cpu_y + 5, "CPU", 13, t["cpu"], weight="700"), + text(48, gpu_y + 5, "GPU", 13, t["gpu"], weight="700"), + f'', + f'', + f'', + text(req, cpu_y - 14, "request", 12, t["muted"], anchor="middle"), + f'', + text(launch, cpu_y - 14, "cudaLaunchKernel returns", 12, t["muted"], anchor="middle"), + f'', + text((kstart + kend) / 2, gpu_y + 28, "kernel", 12, t["muted"], anchor="middle"), + "", + '', + f'', + text((req + launch) / 2, cpu_y + 22, "request → launch 1.14 ms (first-after)", 13, t["text"], anchor="middle"), + "", + '', + f'', + text(launch - 10, (cpu_y + gpu_y) / 2 + 8, "launch → kernel 99 µs (exact)", 13, t["text"], anchor="end"), + "", + '', + text(kend + 12, gpu_y + 5, "kernel 1.2 µs (exact)", 13, t["text"]), + "", + ] + return "".join(parts) + + +def render(theme_name: str, animated: bool = True) -> str: + t = THEMES[theme_name] + body = [ + f'', + f'', + '' + '', + text(WIDTH / 2, 47, "Why is PID 4242 slow?", 14, t["muted"], cls="sans", anchor="middle"), + '', + command(90, "xprobe measure --pid 4242 --triage --duration-ms 2000", "cmd1", t), + finding(130, 1, "CPU_HOTSPOT", "libgomp worker spinning: 77% of sampled stacks", 0.76, t["cpu"], "find1", t), + finding(174, 2, "GPU_KERNEL_DOMINANT", "ampere_sgemm_128x64_nn: all kernel time, GPU busy 54%", 0.54, t["gpu"], "find2", t), + finding(218, 3, "GPU_CLOCK_LIMITED", "sw_power_cap under load", None, t["env"], "find3", t), + f'{text(48, 262, "→ each finding ships a ready next spec", 13, t["prompt"])}', + command(304, "xprobe measure --spec next.json", "cmd2", t), + timeline(t), + f'' + f'{text(WIDTH / 2, 494, "Two commands. Bounded capture. No daemon. Evidence an agent can read.", 15, t["text"], cls="sans", weight="600", anchor="middle")}' + "", + "", + ] + return ( + f'' + "xprobe: triage a slow process, then measure the exact latency" + "A terminal runs xprobe triage on a PyTorch CUDA process. Ranked " + "findings show a CPU hotspot, a dominant GPU kernel, and power-capped GPU clocks. " + "A second command measures request to launch 1.14 ms, launch to kernel 99 " + "microseconds, and kernel duration 1.2 microseconds on CPU and GPU timelines." + + (f"" if animated else + f"") + + "".join(body) + + "\n" + ) + + +def social_card() -> str: + """A 1280x640 repository social preview built from the final frame.""" + t = THEMES["dark"] + flow = render("dark", animated=False).split(">", 1)[1].rsplit("", 1)[0] + flow = flow.replace('' + f'' + f'' + + text(64, 96, "xprobe", 56, t["text"], cls="sans", weight="700") + + text(64, 140, "Why is my CPU, Python, or CUDA process slow? Two commands.", 26, t["muted"], cls="sans") + + f'{flow}' + + "\n" + ) + + +def main() -> None: + if len(sys.argv) != 2: + raise SystemExit("usage: render-readme-art.py ") + output = pathlib.Path(sys.argv[1]) + output.mkdir(parents=True, exist_ok=True) + for theme in THEMES: + (output / f"xprobe-flow-{theme}.svg").write_text(render(theme)) + (output / "social-preview.svg").write_text(social_card()) + + +if __name__ == "__main__": + main() diff --git a/skills/xprobe-measure-latency/references/setup.md b/skills/xprobe-measure-latency/references/setup.md index a2bf8cc..3ec6e54 100644 --- a/skills/xprobe-measure-latency/references/setup.md +++ b/skills/xprobe-measure-latency/references/setup.md @@ -11,7 +11,7 @@ absent, reports another version, or fails a required capability: ```bash command -v xprobe >/dev/null 2>&1 && xprobe --version curl --proto '=https' --tlsv1.2 -fsSL \ - https://raw.githubusercontent.com/itdevwu/xprobe/v0.6.0/install.sh \ + https://raw.githubusercontent.com/itdevwu/xprobe/v0.6.1/install.sh \ -o /tmp/xprobe-install.sh sh /tmp/xprobe-install.sh export PATH="$HOME/.local/bin:$PATH" @@ -45,7 +45,7 @@ The release archive needs host glibc 2.34 or newer. Otherwise build the matching source with the host glibc: ```bash -git clone --depth 1 --branch v0.6.0 https://github.com/itdevwu/xprobe.git +git clone --depth 1 --branch v0.6.1 https://github.com/itdevwu/xprobe.git cd xprobe mamba env create --file environment.yml mamba run -n xprobe-dev just build @@ -83,6 +83,6 @@ references, examples, and analyzer are part of the contract: ```bash npx skills@1 add \ - https://github.com/itdevwu/xprobe/tree/v0.6.0/skills/xprobe-measure-latency \ + https://github.com/itdevwu/xprobe/tree/v0.6.1/skills/xprobe-measure-latency \ --global ``` diff --git a/tests/agent-contract/test_contract.py b/tests/agent-contract/test_contract.py index 7c321dc..93ffcf2 100755 --- a/tests/agent-contract/test_contract.py +++ b/tests/agent-contract/test_contract.py @@ -193,6 +193,25 @@ def check_skill(workspace: pathlib.Path, binary: pathlib.Path) -> None: assert required in setup, required +def check_readme_art(workspace: pathlib.Path) -> None: + """README art is generated; committed SVGs must match the renderer.""" + with tempfile.TemporaryDirectory(prefix="xprobe-art-") as directory: + subprocess.run( + [sys.executable, "-I", workspace / "scripts/render-readme-art.py", directory], + check=True, + ) + rendered = sorted(pathlib.Path(directory).glob("*.svg")) + assert rendered + for path in rendered: + committed = workspace / "docs/assets" / path.name + assert committed.read_text() == path.read_text(), ( + f"{committed} is stale; run scripts/render-readme-art.py docs/assets" + ) + readme = (workspace / "README.md").read_text() + for asset in ("docs/assets/xprobe-flow-light.svg", "docs/assets/xprobe-flow-dark.svg"): + assert asset in readme, asset + + def check_schemas(workspace: pathlib.Path) -> None: schema_paths = sorted((workspace / "schemas").glob("*.schema.json")) assert schema_paths @@ -287,6 +306,7 @@ def main() -> None: check_skill(workspace, binary) check_schemas(workspace) check_installation_docs(workspace) + check_readme_art(workspace) documented = check_documented_flags( binary, [workspace / "README.md", *sorted((workspace / "docs").glob("*.md"))] )