pi-do-always 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # pi-do-always
2
+
3
+ A [Pi](https://github.com/earendil-works/pi) extension that gives you a `/do-always` command for your
4
+ repeated "do the usual" prompts.
5
+
6
+ Type `/do-always` → a numbered list of tasks appears → press a number (or arrows + Enter) →
7
+ the task's prompt is **filled into the input editor**. Review it, tweak it, press Enter to run.
8
+
9
+ ```text
10
+ ┌ do-always — pick a task ┐
11
+ │ 1. review — Review code and double-check changes │
12
+ │ 2. readme — Update the README │
13
+ │ 3. tests — Run tests and fix failures │
14
+ │ 4. commit — Prepare a clean commit │
15
+ │ 5. cleanup — Propose a plan to clean up dead code and duplicates │
16
+ │ 6. release — Prepare a release (version, changelog, tag) │
17
+ │ 7. security — Security audit — plan proposal │
18
+ │ 8. perf — Performance review — plan proposal │
19
+ └───────────────────────────────────────────────────────────────────┘
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ |Command|What it does|
25
+ |---|---|
26
+ |`/do-always` or `Ctrl+Shift+P`|Show the numbered task selector|
27
+ |`/do-always 2`|Fill the prompt for task #2 directly|
28
+ |`/do-always review`|Fill the prompt for the task named `review` (task names autocomplete after `/do-always`)|
29
+ |`/do-always list`|Print the task list|
30
+ |`/do-always list-details`|Show the full prompt text each task will inject|
31
+
32
+ In non-interactive modes (no TUI) there is no editor to fill, so the selected prompt is sent
33
+ as a user message instead.
34
+
35
+ ## Install
36
+
37
+ Install it as a Pi package, which loads the bundled `index.ts` (and its `tasks.ts`) without
38
+ managing symlinks:
39
+
40
+ ```bash
41
+ pi install ./pi-always # from a local copy of this repo
42
+ pi install git:github.com/aginies/pi-always # from the git repo
43
+ ```
44
+
45
+ (It is not on npm yet; once published it will be `pi install npm:pi-do-always`.)
46
+
47
+ Manage it with `pi list` (to see installed sources) and `pi remove <source>` using the same
48
+ source you installed with (e.g. `pi remove git:github.com/aginies/pi-always`). Alternatively, it is auto-discovered from
49
+ the extensions directory, so you can symlink it instead:
50
+
51
+ ```bash
52
+ ln -s "$PWD" ~/.pi/agent/extensions/do-always # uninstall with: rm that symlink
53
+ ```
54
+
55
+ For development you can also load it explicitly: `npm run dev` (runs `pi --extension ./index.ts`).
56
+
57
+ ## Tasks configuration
58
+
59
+ Tasks are read from JSON files (an array of tasks, or `{"tasks": [...]}`):
60
+
61
+ |File|Scope|
62
+ |---|---|
63
+ |`~/.pi/agent/do-always.json`|Global (all projects)|
64
+ |`<project>/.pi/do-always.json`|Project-local; overrides global tasks with the same `name`|
65
+
66
+ If neither file exists, the built-in defaults (review, readme, tests, commit, cleanup, release, security, perf) are used.
67
+ This repo ships a sample in [`do-always.json`](./do-always.json) — copy it to one of the
68
+ locations above to make it your own:
69
+
70
+ ```json
71
+ [
72
+ {
73
+ "name": "review",
74
+ "description": "Review code and double-check changes",
75
+ "prompt": "Review the recent code changes in this project. Check `git status` and `git diff` ..."
76
+ }
77
+ ]
78
+ ```
79
+
80
+ Fields:
81
+
82
+ - `name` (required) — short unique id, used for `/do-always <name>`
83
+ - `description` (optional) — one-line label shown in the selector
84
+ - `prompt` (required) — the text filled into the editor
85
+
86
+ Reload Pi (or start a new session) after editing a config file.
87
+
88
+ ## Development
89
+
90
+ ```bash
91
+ npm install # dev dependencies (pi packages + typescript)
92
+ npm run typecheck # tsc --noEmit
93
+ npm test # run the unit tests (node:test + tsx, in test/)
94
+ ```
95
+
96
+ The pure task logic (`parseConfig`, `mergeTasks`, `resolveTask`, `formatList`) lives in
97
+ [`tasks.ts`](./tasks.ts) with no Pi dependencies, so it is unit-tested independently of the
98
+ Pi runtime. The extension (`index.ts`) imports that logic and adds only the Pi UI.
99
+
100
+ No build step is needed — Pi loads the TypeScript directly via jiti.
101
+
102
+ ## Publishing
103
+
104
+ To publish on npm:
105
+
106
+ ```bash
107
+ npm login
108
+ npm publish
109
+ ```
110
+
111
+ `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` are declared as `peerDependencies`
112
+ with a `"*"` range and are **not** bundled — Pi supplies them to extensions. The `pi-package`
113
+ keyword makes this package eligible for the [Pi package gallery](https://pi.dev/packages).
package/do-always.json ADDED
@@ -0,0 +1,42 @@
1
+ [
2
+ {
3
+ "name": "review",
4
+ "description": "Review code and double-check changes",
5
+ "prompt": "Review the recent code changes in this project. Check `git status` and `git diff` to see what changed, then double-check the changes for bugs, edge cases, security issues, and consistency with the rest of the codebase. Do a plan proposal for the fixes."
6
+ },
7
+ {
8
+ "name": "readme",
9
+ "description": "Update the README",
10
+ "prompt": "Update the README to match the current state of the project. Check the code, scripts, and configuration, then update the README sections that are now out of date (description, installation, usage, configuration). Keep it concise and accurate."
11
+ },
12
+ {
13
+ "name": "tests",
14
+ "description": "Run tests and fix failures",
15
+ "prompt": "Run the project's test suite (and type check / lint if available). If anything fails, diagnose and fix the failures, then re-run until green. Summarize the results."
16
+ },
17
+ {
18
+ "name": "commit",
19
+ "description": "Prepare a clean commit",
20
+ "prompt": "Prepare the working tree for a clean commit: review `git status` and `git diff`, stage the relevant changes, and write a clear commit message describing what changed and why. Do not push."
21
+ },
22
+ {
23
+ "name": "cleanup",
24
+ "description": "Propose a plan to clean up dead code and duplicates",
25
+ "prompt": "Scan the project for dead code, unused imports, commented-out blocks, and duplicated logic. Do a plan proposal for the removals and consolidations, keeping behavior unchanged. Do not make any changes yet."
26
+ },
27
+ {
28
+ "name": "release",
29
+ "description": "Prepare a release (version, changelog, tag)",
30
+ "prompt": "Prepare a release for this project: check `git log` since the last tag, update the version in package.json (or the equivalent location), add a changelog entry summarizing the changes, and create a git tag. Do not push."
31
+ },
32
+ {
33
+ "name": "security",
34
+ "description": "Security audit — plan proposal",
35
+ "prompt": "Audit this project for security issues: hardcoded secrets or credentials, unsafe patterns (injection, path traversal, unsafe deserialization), and vulnerable or outdated dependencies. Do a plan proposal for the fixes. Do not make any changes yet."
36
+ },
37
+ {
38
+ "name": "perf",
39
+ "description": "Performance review — plan proposal",
40
+ "prompt": "Review this project for likely performance bottlenecks: inefficient algorithms, redundant I/O or computation, missing caching, and memory leaks. Do a plan proposal for the optimizations, prioritized by impact. Do not make any changes yet."
41
+ }
42
+ ]
package/index.ts ADDED
@@ -0,0 +1,202 @@
1
+ /**
2
+ * do-always — Pi extension
3
+ *
4
+ * Registers /do-always: shows a numbered list of common tasks
5
+ * ("review code and double-check changes", "update the README", ...).
6
+ * Pick one by number (press 1-9), or navigate with arrows + Enter.
7
+ * The selected task's prompt is filled into the input editor — review,
8
+ * tweak, then press Enter to run it.
9
+ *
10
+ * Config (JSON array of tasks; project file overrides global by name):
11
+ * ~/.pi/agent/do-always.json (global)
12
+ * <cwd>/.pi/do-always.json (project)
13
+ *
14
+ * [
15
+ * {
16
+ * "name": "review",
17
+ * "description": "Review code and double-check changes",
18
+ * "prompt": "Review the recent changes..."
19
+ * }
20
+ * ]
21
+ *
22
+ * Usage:
23
+ * /do-always → numbered selector (or press Ctrl+Shift+P)
24
+ * /do-always 2 → fill prompt for task #2
25
+ * /do-always review → fill prompt for task named "review"
26
+ * /do-always list → print the task list
27
+ * /do-always list-details → show the full prompt text each task injects
28
+ *
29
+ * If no config file exists, built-in default tasks are used.
30
+ */
31
+
32
+ import { existsSync, readFileSync } from "node:fs";
33
+ import { join } from "node:path";
34
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
35
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
36
+ import { Container, Key, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
37
+ import { DEFAULT_TASKS, formatList, mergeTasks, parseConfig, resolveTask, type DoAlwaysTask } from "./tasks";
38
+
39
+ /**
40
+ * Load tasks from config files.
41
+ * Project-local tasks override global tasks with the same name; new ones are appended.
42
+ * Falls back to DEFAULT_TASKS when nothing is defined.
43
+ */
44
+ function loadTasks(cwd: string): DoAlwaysTask[] {
45
+ const globalPath = join(getAgentDir(), "do-always.json");
46
+ const projectPath = join(cwd, CONFIG_DIR_NAME, "do-always.json");
47
+
48
+ const globalTasks = existsSync(globalPath) ? parseConfig(readFileSync(globalPath, "utf-8"), globalPath) : [];
49
+ const projectTasks = existsSync(projectPath) ? parseConfig(readFileSync(projectPath, "utf-8"), projectPath) : [];
50
+
51
+ return mergeTasks(globalTasks, projectTasks, DEFAULT_TASKS);
52
+ }
53
+
54
+ export default function doAlwaysExtension(pi: ExtensionAPI) {
55
+ let tasks: DoAlwaysTask[] = [];
56
+ let loadedCwd = ""; // cwd the cached `tasks` were loaded for
57
+
58
+ pi.on("session_start", async (_event, ctx) => {
59
+ loadedCwd = ctx.cwd;
60
+ tasks = loadTasks(ctx.cwd);
61
+ });
62
+
63
+ /** Put the task prompt into the editor (TUI) or send it as a user message (other modes). */
64
+ async function fillPrompt(task: DoAlwaysTask, ctx: ExtensionContext): Promise<void> {
65
+ if (ctx.mode === "tui") {
66
+ ctx.ui.setEditorText(task.prompt);
67
+ ctx.ui.notify(`do-always: prompt for "${task.name}" filled — press Enter to run`, "info");
68
+ } else {
69
+ await pi.sendUserMessage(task.prompt);
70
+ }
71
+ }
72
+
73
+ /** Numbered selector: press 1-9 to pick, or arrows + Enter, Esc to cancel. */
74
+ async function showSelector(ctx: ExtensionContext): Promise<void> {
75
+ const items: SelectItem[] = tasks.map((t, i) => ({
76
+ value: String(i),
77
+ label: `${i + 1}. ${t.name}`,
78
+ description: t.description,
79
+ }));
80
+
81
+ const selected = await ctx.ui.custom<number | null>((tui, theme, _kb, done) => {
82
+ let settled = false;
83
+ const finish = (value: number | null) => {
84
+ if (settled) return;
85
+ settled = true;
86
+ done(value);
87
+ };
88
+
89
+ const container = new Container();
90
+ container.addChild(new Text(theme.fg("accent", theme.bold("do-always — pick a task"))));
91
+
92
+ const selectList = new SelectList(items, Math.min(items.length, 10), {
93
+ selectedPrefix: (text) => theme.fg("accent", text),
94
+ selectedText: (text) => theme.fg("accent", text),
95
+ description: (text) => theme.fg("muted", text),
96
+ scrollInfo: (text) => theme.fg("dim", text),
97
+ noMatch: (text) => theme.fg("warning", text),
98
+ });
99
+ selectList.onSelect = (item) => finish(Number(item.value));
100
+ selectList.onCancel = () => finish(null);
101
+ container.addChild(selectList);
102
+ container.addChild(new Text(theme.fg("dim", "1-9 pick by number • ↑↓ navigate • enter select • esc cancel")));
103
+
104
+ return {
105
+ render(width: number) {
106
+ return container.render(width);
107
+ },
108
+ invalidate() {
109
+ container.invalidate();
110
+ },
111
+ handleInput(data: string) {
112
+ // Direct pick by number (1-9)
113
+ if (/^[1-9]$/.test(data) && Number(data) <= tasks.length) {
114
+ finish(Number(data) - 1);
115
+ return;
116
+ }
117
+ selectList.handleInput(data);
118
+ tui.requestRender();
119
+ },
120
+ };
121
+ });
122
+
123
+ if (selected === null || selected === undefined) return;
124
+ await fillPrompt(tasks[selected], ctx);
125
+ }
126
+
127
+ pi.registerCommand("do-always", {
128
+ description: "Pick a common task (review, readme, ...) by number — fills the prompt",
129
+ getArgumentCompletions: (prefix) => {
130
+ const p = prefix.trim().toLowerCase();
131
+ const matches = [
132
+ { value: "list", label: "list" },
133
+ { value: "list-details", label: "list-details" },
134
+ ...tasks.map((t, i) => ({ value: t.name, label: `${i + 1}. ${t.name}` })),
135
+ ].filter((c) => c.value.toLowerCase().includes(p));
136
+ return matches.length > 0 ? matches : null;
137
+ },
138
+ handler: async (args, ctx) => {
139
+ await runDoAlways(args, ctx);
140
+ },
141
+ });
142
+
143
+ // Keyboard shortcut: open the task selector without typing the command.
144
+ pi.registerShortcut(Key.ctrlShift("p"), {
145
+ description: "do-always: pick a common task",
146
+ handler: async (ctx) => {
147
+ await runDoAlways("", ctx);
148
+ },
149
+ });
150
+
151
+ async function runDoAlways(args: string, ctx: ExtensionContext): Promise<void> {
152
+ // Reload when the active directory changes, so switching projects
153
+ // mid-session serves the right config instead of stale tasks.
154
+ if (ctx.cwd !== loadedCwd) {
155
+ loadedCwd = ctx.cwd;
156
+ tasks = loadTasks(ctx.cwd);
157
+ }
158
+
159
+ const arg = args.trim();
160
+
161
+ if (!arg) {
162
+ if (ctx.mode === "tui") {
163
+ await showSelector(ctx);
164
+ } else {
165
+ ctx.ui.notify(`do-always tasks (use /do-always <number|name>):\n${formatList(tasks)}`, "info");
166
+ }
167
+ return;
168
+ }
169
+
170
+ if (arg.toLowerCase() === "list") {
171
+ ctx.ui.notify(formatList(tasks), "info");
172
+ return;
173
+ }
174
+
175
+ if (arg.toLowerCase() === "list-details") {
176
+ // Display only — the description is metadata; selecting a task injects just its prompt.
177
+ const details = tasks
178
+ .map((t, i) => {
179
+ const lines = [`${i + 1}. ${t.name}`];
180
+ if (t.description) lines.push(` description: ${t.description}`);
181
+ lines.push(" prompt (this is what gets injected on select):");
182
+ for (const line of t.prompt.split("\n")) lines.push(` ${line}`);
183
+ return lines.join("\n");
184
+ })
185
+ .join("\n\n");
186
+ if (ctx.mode === "tui") {
187
+ await ctx.ui.editor("do-always — task details (only the prompt is injected; esc to close)", details);
188
+ } else {
189
+ ctx.ui.notify(details, "info");
190
+ }
191
+ return;
192
+ }
193
+
194
+ const task = resolveTask(tasks, arg);
195
+ if (!task) {
196
+ const available = tasks.map((t, i) => `${i + 1}=${t.name}`).join(", ");
197
+ ctx.ui.notify(`do-always: unknown task "${arg}". Available: ${available}`, "error");
198
+ return;
199
+ }
200
+ await fillPrompt(task, ctx);
201
+ }
202
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "pi-do-always",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Pi extension: /do-always — pick a common task by number, it fills your prompt",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extensions",
9
+ "do-always"
10
+ ],
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/aginies/pi-always"
15
+ },
16
+ "homepage": "https://github.com/aginies/pi-always",
17
+ "bugs": {
18
+ "url": "https://github.com/aginies/pi-always/issues"
19
+ },
20
+ "files": [
21
+ "index.ts",
22
+ "tasks.ts",
23
+ "do-always.json",
24
+ "README.md"
25
+ ],
26
+ "pi": {
27
+ "extensions": [
28
+ "./index.ts"
29
+ ]
30
+ },
31
+ "scripts": {
32
+ "dev": "pi --extension ./index.ts",
33
+ "typecheck": "tsc --noEmit",
34
+ "test": "node --import tsx --test test/**/*.test.ts"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-coding-agent": "*",
38
+ "@earendil-works/pi-tui": "*"
39
+ },
40
+ "devDependencies": {
41
+ "@earendil-works/pi-coding-agent": "^0.87.1",
42
+ "@earendil-works/pi-tui": "^0.87.1",
43
+ "@types/node": "^22.0.0",
44
+ "tsx": "^4.19.0",
45
+ "typescript": "^5.5.0"
46
+ }
47
+ }
package/tasks.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * do-always — pure task logic (no Pi dependencies).
3
+ *
4
+ * Kept separate from index.ts so it can be unit-tested without the Pi runtime.
5
+ */
6
+
7
+ export interface DoAlwaysTask {
8
+ /** Short unique name, usable as /do-always <name> */
9
+ name: string;
10
+ /** Short one-line description shown next to the task in the selector */
11
+ description?: string;
12
+ /** Prompt filled into the editor when the task is selected */
13
+ prompt: string;
14
+ }
15
+
16
+ /** A config file can be a bare array of tasks, or {"tasks": [...]}. */
17
+ export type DoAlwaysConfig = DoAlwaysTask[] | { tasks: DoAlwaysTask[] };
18
+
19
+ /** Used when neither config file defines any task. */
20
+ export const DEFAULT_TASKS: DoAlwaysTask[] = [
21
+ {
22
+ name: "review",
23
+ description: "Review code and double-check changes",
24
+ prompt:
25
+ "Review the recent code changes in this project. Check `git status` and `git diff` to see what changed, then double-check the changes for bugs, edge cases, security issues, and consistency with the rest of the codebase. Do a plan proposal for the fixes.",
26
+ },
27
+ {
28
+ name: "readme",
29
+ description: "Update the README",
30
+ prompt:
31
+ "Update the README to match the current state of the project. Check the code, scripts, and configuration, then update the README sections that are now out of date (description, installation, usage, configuration). Keep it concise and accurate.",
32
+ },
33
+ {
34
+ name: "tests",
35
+ description: "Run tests and fix failures",
36
+ prompt:
37
+ "Run the project's test suite (and type check / lint if available). If anything fails, diagnose and fix the failures, then re-run until green. Summarize the results.",
38
+ },
39
+ {
40
+ name: "commit",
41
+ description: "Prepare a clean commit",
42
+ prompt:
43
+ "Prepare the working tree for a clean commit: review `git status` and `git diff`, stage the relevant changes, and write a clear commit message describing what changed and why. Do not push.",
44
+ },
45
+ {
46
+ name: "cleanup",
47
+ description: "Propose a plan to clean up dead code and duplicates",
48
+ prompt:
49
+ "Scan the project for dead code, unused imports, commented-out blocks, and duplicated logic. Do a plan proposal for the removals and consolidations, keeping behavior unchanged. Do not make any changes yet.",
50
+ },
51
+ {
52
+ name: "release",
53
+ description: "Prepare a release (version, changelog, tag)",
54
+ prompt:
55
+ "Prepare a release for this project: check `git log` since the last tag, update the version in package.json (or the equivalent location), add a changelog entry summarizing the changes, and create a git tag. Do not push.",
56
+ },
57
+ {
58
+ name: "security",
59
+ description: "Security audit — plan proposal",
60
+ prompt:
61
+ "Audit this project for security issues: hardcoded secrets or credentials, unsafe patterns (injection, path traversal, unsafe deserialization), and vulnerable or outdated dependencies. Do a plan proposal for the fixes. Do not make any changes yet.",
62
+ },
63
+ {
64
+ name: "perf",
65
+ description: "Performance review — plan proposal",
66
+ prompt:
67
+ "Review this project for likely performance bottlenecks: inefficient algorithms, redundant I/O or computation, missing caching, and memory leaks. Do a plan proposal for the optimizations, prioritized by impact. Do not make any changes yet.",
68
+ },
69
+ ];
70
+
71
+ /**
72
+ * Parse and validate a config file's contents.
73
+ * Accepts a bare array of tasks or {"tasks": [...]}. Invalid entries are skipped with a warning.
74
+ * Returns [] when the JSON is malformed or the shape is wrong.
75
+ */
76
+ export function parseConfig(
77
+ raw: string,
78
+ path: string,
79
+ onError: (message: string) => void = () => {},
80
+ ): DoAlwaysTask[] {
81
+ let data: DoAlwaysConfig;
82
+ try {
83
+ data = JSON.parse(raw);
84
+ } catch (err) {
85
+ onError(`do-always: invalid JSON in ${path}: ${err}`);
86
+ return [];
87
+ }
88
+
89
+ const list = Array.isArray(data) ? data : data?.tasks;
90
+
91
+ if (!Array.isArray(list)) {
92
+ onError(`do-always: ${path} must be a JSON array of tasks or {"tasks": [...]}`);
93
+ return [];
94
+ }
95
+
96
+ const tasks: DoAlwaysTask[] = [];
97
+ for (const entry of list) {
98
+ const t = entry as Partial<DoAlwaysTask> | null;
99
+ if (t && typeof t.name === "string" && t.name.length > 0 && typeof t.prompt === "string" && t.prompt.length > 0) {
100
+ tasks.push({
101
+ name: t.name,
102
+ description: typeof t.description === "string" ? t.description : undefined,
103
+ prompt: t.prompt,
104
+ });
105
+ } else {
106
+ onError(`do-always: skipping invalid task in ${path} (each task needs "name" and "prompt")`);
107
+ }
108
+ }
109
+ return tasks;
110
+ }
111
+
112
+ /**
113
+ * Merge project-local tasks over global tasks.
114
+ * A project task with a name matching a global task replaces it; new names are appended.
115
+ * Returns fallback when the merged result is empty.
116
+ */
117
+ export function mergeTasks(globalTasks: DoAlwaysTask[], projectTasks: DoAlwaysTask[], fallback: DoAlwaysTask[]): DoAlwaysTask[] {
118
+ const merged = [...globalTasks];
119
+ for (const task of projectTasks) {
120
+ const idx = merged.findIndex((t) => t.name === task.name);
121
+ if (idx >= 0) merged[idx] = task;
122
+ else merged.push(task);
123
+ }
124
+ return merged.length > 0 ? merged : fallback;
125
+ }
126
+
127
+ /**
128
+ * Resolve a task from a command argument: by number (1-based) or by name (case-insensitive).
129
+ */
130
+ export function resolveTask(tasks: DoAlwaysTask[], arg: string): DoAlwaysTask | undefined {
131
+ const a = arg.trim();
132
+ if (!a) return undefined;
133
+ if (/^\d+$/.test(a)) {
134
+ const n = Number(a);
135
+ return n >= 1 && n <= tasks.length ? tasks[n - 1] : undefined;
136
+ }
137
+ return tasks.find((t) => t.name.toLowerCase() === a.toLowerCase());
138
+ }
139
+
140
+ /** Render a numbered, one-line-per-task list. */
141
+ export function formatList(tasks: DoAlwaysTask[]): string {
142
+ return tasks.map((t, i) => `${i + 1}. ${t.name} — ${t.description ?? ""}`).join("\n");
143
+ }