diff --git a/COMMANDS.md b/COMMANDS.md index 181ef21..15bc58d 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -74,7 +74,7 @@ Every run ends with a verdict line: `✓ ok · 4 files changed · tests green · |---|---| | `--local` | Use the local brain (Python/Ollama) instead of the cloud. | | `--pool ` | Context pool size in GB (status-bar reach = pool × 233M tokens). | -| `--effort ` | Effort tier: `LOW` \| `MED` \| `MAX` \| `ULTRA` \| `CODEPRO` (overrides the saved `/effort` dial). | +| `--effort ` | Effort tier: `LOW` \| `MED` \| `HIGH` \| `MAX` \| `ULTRA` \| `CODEPRO` (overrides the saved `/effort` dial). | | `--test-cmd ` | Command the verification gate runs (unverified without it). | | `--quiet` | Plain output (strip the personality frames). | | `--interactive` | Pause at each stage boundary to type a steer (TTY only). | @@ -188,7 +188,7 @@ aether config set autoApply true |---|---|---| | `baseUrl` | string | Aether API base URL. | | `defaultModel` | string | Model used when `--model` is omitted. | -| `defaultEffort` | string | Effort tier for `aether code` when `--effort` is omitted (`LOW`\|`MED`\|`MAX`\|`ULTRA`\|`CODEPRO`, `""` = server default). Same dial as `/effort`. | +| `defaultEffort` | string | Effort tier for `aether code` when `--effort` is omitted (`LOW`\|`MED`\|`HIGH`\|`MAX`\|`ULTRA`\|`CODEPRO`, `""` = server default). Same dial as `/effort`. | | `permissionMode` | `ask`\|`auto`\|`skip` | Gate edits/commands: prompt every time, auto with confirm, or fully autonomous. | | `autoApply` | bool | Apply streamed edits without a per-edit prompt. | | `telemetry` | bool | Anonymous usage telemetry opt-in. | @@ -211,7 +211,7 @@ mirrors the live registry in `src/commands/slash_registry.ts`. | `/agents` | View active agent sessions (name, status, time, UVT, task). | | `/agent ` | Switch orchestrator (Neo / Kronus) — opens the picker with no arg. | | `/tier` | Show your plan tier, default, and available counts. | -| `/effort [tier\|1-5]` | Show or set the effort dial (`LOW`→`CODEPRO`). Persists to your Aether config and drives `aether code`. `CODEPRO` gets the banner. | +| `/effort [tier\|1-6]` | Show or set the effort dial (`LOW`→`CODEPRO`). The dial moves phases, sub-agent fan-out, repair passes and the UVT ceiling; `CODEPRO` additionally enables System-2 review and unlimited context, and gets the banner. Persists to your Aether config and drives `aether code`. | | `/audit [n]` | Recent chain-of-custody receipts. | | `/doctor [deep]` | Run ordered diagnostics; `deep` adds bounded checks. | | `/clear` | Clear the screen. | diff --git a/package-lock.json b/package-lock.json index 3cfd789..59a4be2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "aether-agent": "dist/src/main.js" }, "devDependencies": { - "@types/node": "^26.1.1", + "@types/node": "^24.0.0", "typescript": "^7.0.2" }, "engines": { @@ -21,13 +21,13 @@ } }, "node_modules/@types/node": { - "version": "26.1.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", - "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~7.18.0" } }, "node_modules/@typescript/typescript-aix-ppc64": { @@ -406,9 +406,9 @@ } }, "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" } diff --git a/package.json b/package.json index 7948441..9093bf3 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "url": "https://github.com/DBarr3/aether-agent/issues" }, "devDependencies": { - "@types/node": "^26.1.1", + "@types/node": "^24.0.0", "typescript": "^7.0.2" } } diff --git a/src/commands/models.ts b/src/commands/models.ts index 7eab37b..0a19195 100644 --- a/src/commands/models.ts +++ b/src/commands/models.ts @@ -5,11 +5,19 @@ // Source: GET /models (lib/plan_tiers.TIER_MATRIX SSOT). One list, `kind` // distinguishes models from orchestrators. Locked (unavailable-on-tier) rows // are shown with a lock so users see what an upgrade unlocks. +// +// Layout note: columns are padded by VISIBLE width, not string length. The +// marker column mixes "●" (one column) with "🔒" (two), and ids and labels vary +// in length, so the previous tab-separated rows drifted out of alignment the +// moment an id crossed a tab stop — putting the lock in a different place on +// every row, which is exactly where the eye needs it to be steady. import type { AppContext } from "../core/context.js"; -import type { CatalogResponse } from "../types.js"; +import type { CatalogItem, CatalogResponse } from "../types.js"; import { MODELS_PATH } from "../core/transport.js"; import { saveConfig } from "../core/config.js"; +import { theme } from "../ui/theme.js"; +import { visibleWidth } from "../ui/text.js"; export async function cmdModels(ctx: AppContext, argv: string[]): Promise { const sub = argv[0]; @@ -31,10 +39,15 @@ export async function cmdModels(ctx: AppContext, argv: string[]): Promise m.kind !== "orchestrator"); + const orchestrators = cat.models.filter((m) => m.kind === "orchestrator"); + + process.stdout.write(renderHeader(cat.tier, activeDefault)); + if (models.length) process.stdout.write(renderGroup("MODELS", models, activeDefault)); + if (orchestrators.length) { + process.stdout.write(renderGroup("ORCHESTRATORS", orchestrators, activeDefault)); } + process.stdout.write(renderLegend(cat.models, "aether models use ")); return 0; } @@ -45,15 +58,69 @@ export async function cmdAgents(ctx: AppContext): Promise { process.stdout.write(JSON.stringify(orchestrators, null, 2) + "\n"); return 0; } - for (const a of orchestrators) { - process.stdout.write(renderRow(a, ctx.cfg.defaultModel || cat.default)); + const activeDefault = ctx.cfg.defaultModel || cat.default; + process.stdout.write(renderHeader(cat.tier, activeDefault)); + if (orchestrators.length) { + process.stdout.write(renderGroup("ORCHESTRATORS", orchestrators, activeDefault)); } + process.stdout.write(renderLegend(orchestrators, "aether agent ")); return 0; } -function renderRow(m: CatalogResponse["models"][number], activeDefault: string): string { - const mark = m.id === activeDefault ? "*" : m.available ? " " : "🔒"; - const kind = m.kind === "orchestrator" ? "orch " : "model"; - const cap = m.monthly_uvt_cap != null ? ` cap ${m.monthly_uvt_cap}` : ""; - return `${mark} ${m.id}\t${kind}\t${m.tier_min ?? "-"}\t${m.label}${cap}\n`; +// ── rendering ─────────────────────────────────────────────────────────────── + +/** Pad to `w` VISIBLE columns. Styled text and wide glyphs both pad correctly. */ +export function padVisible(s: string, w: number): string { + return s + " ".repeat(Math.max(0, w - visibleWidth(s))); +} + +/** 128000 -> "128k", 1000000 -> "1M". Long digit runs read as noise in a column. */ +export function compactNumber(n: number): string { + if (n >= 1_000_000) return `${Math.round(n / 100_000) / 10}M`.replace(".0M", "M"); + if (n >= 1_000) return `${Math.round(n / 100) / 10}k`.replace(".0k", "k"); + return String(n); +} + +function renderHeader(tier: string, activeDefault: string): string { + return ( + "\n " + + theme.dim("tier ") + theme.bold(theme.cyan(tier)) + + theme.dim(" default ") + theme.bold(activeDefault) + + "\n" + ); +} + +function renderGroup(title: string, items: CatalogItem[], activeDefault: string): string { + // Size every column to its widest cell, so nothing truncates and nothing drifts. + const idW = Math.max(...items.map((m) => visibleWidth(m.id)), 4); + const provW = Math.max(...items.map((m) => visibleWidth(m.provider ?? "")), 0); + const labelW = Math.max(...items.map((m) => visibleWidth(m.label)), 0); + + const rows = items.map((m) => { + const isDefault = m.id === activeDefault; + // The marker occupies two columns in every state, so the id column starts at + // the same offset whether or not the row is locked. + const marker = isDefault ? theme.cyan("● ") : m.available ? " " : "🔒"; + const id = isDefault ? theme.bold(theme.cyan(m.id)) : m.available ? m.id : theme.dim(m.id); + const meta = [ + padVisible(theme.dim(m.provider ?? ""), provW), + padVisible(m.available ? m.label : theme.dim(m.label), labelW), + m.context_window != null ? theme.dim(compactNumber(m.context_window)) : "", + ]; + // The one thing a locked row has to answer is "what unlocks it". + const gate = !m.available && m.tier_min ? theme.yellow(`needs ${m.tier_min}`) : ""; + const cap = m.available && m.monthly_uvt_cap != null + ? theme.dim(`cap ${compactNumber(m.monthly_uvt_cap)}`) + : ""; + return ` ${marker} ${padVisible(id, idW)} ${meta.join(" ")} ${gate || cap}`.trimEnd(); + }); + + return `\n ${theme.iceBlue(title)}\n${rows.join("\n")}\n`; +} + +function renderLegend(items: CatalogItem[], useHint: string): string { + const locked = items.filter((m) => !m.available).length; + const parts = [theme.cyan("●") + theme.dim(" default")]; + if (locked) parts.push("🔒" + theme.dim(` ${locked} locked on your tier`)); + return `\n ${parts.join(theme.dim(" · "))}\n ${theme.dim(useHint)}\n\n`; } diff --git a/src/commands/slash_help.ts b/src/commands/slash_help.ts index 42e380d..8429d36 100644 --- a/src/commands/slash_help.ts +++ b/src/commands/slash_help.ts @@ -11,7 +11,9 @@ export function printSlashHelp(out: Writable, target = ""): void { sections: SLASH_SECTIONS, target: target.trim().replace(/^\//, ""), footer: [ - "/help for detail ? Tab completes slash commands.", + // The separator here was a literal "?" — a mangled "·" that shipped as a + // question mark and read as part of the sentence. + "/help for detail · /help searches · Tab completes slash commands.", "/model or /agent with no argument opens the picker.", ], })); diff --git a/src/commands/slash_registry.ts b/src/commands/slash_registry.ts index c0b1383..8aa3361 100644 --- a/src/commands/slash_registry.ts +++ b/src/commands/slash_registry.ts @@ -47,7 +47,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [ { name: "tier", summary: "plan tier + default model", section: "Session" }, // effort tier persists to config and rides TaskCommand.effort into every // `aether code` run — see setEffort() in slash.ts for the wire contract. - { name: "effort", args: "[tier|1-5]", summary: "effort dial (LOW to CODEPRO), drives aether code", section: "Session" }, + { name: "effort", args: "[tier|1-6]", summary: "effort dial (LOW to CODEPRO), drives aether code", section: "Session" }, { name: "audit", args: "[n]", summary: "recent audit trail", section: "Session" }, { name: "doctor", args: "[deep]", summary: "structured runtime diagnostics", section: "Session" }, { name: "clear", summary: "clear screen", section: "Session" }, diff --git a/src/core/command_registry.ts b/src/core/command_registry.ts index 3cbbe2a..1edd6ba 100644 --- a/src/core/command_registry.ts +++ b/src/core/command_registry.ts @@ -1,3 +1,6 @@ +import { theme } from "../ui/theme.js"; +import { visibleWidth } from "../ui/text.js"; + export interface CommandSpec { name: string; args?: string; @@ -67,35 +70,100 @@ export interface HelpOptions { footer?: string[]; } +/** Pad to `w` VISIBLE columns — styled text carries zero-width escapes. */ +function padCol(s: string, w: number): string { + return s + " ".repeat(Math.max(0, w - visibleWidth(s))); +} + +/** ` ` — the canonical way a command is written. */ +function usageOf(command: CommandSpec, prefix: string): string { + return `${prefix}${command.name}${command.args ? " " + command.args : ""}`; +} + export function renderRegistryHelp(options: HelpOptions): string { const target = options.target?.trim(); - if (target) { - const command = findRegisteredCommand(options.commands, target); - if (!command) { - const suggestion = suggestRegisteredCommand(target, commandNames(options.commands)); - return `Unknown command: ${options.prefix}${target}${suggestion ? `\nDid you mean ${options.prefix}${suggestion}?` : ""}\n`; - } - const aliases = command.aliases?.length ? `\nAliases: ${command.aliases.map((alias) => options.prefix + alias).join(", ")}` : ""; - return `Usage: ${options.prefix}${command.name}${command.args ? " " + command.args : ""}\n${command.summary}\nSection: ${command.section}${aliases}\n`; + if (target) return renderTargetHelp(options, target); + + const lines = [theme.bold(options.title)]; + if (options.intro) lines.push("", theme.dim(options.intro)); + if (options.usage?.length) { + lines.push("", theme.dim("Usage:"), ...options.usage.map((line) => " " + line)); } - const lines = [options.title]; - if (options.intro) lines.push("", options.intro); - if (options.usage?.length) lines.push("", "Usage:", ...options.usage.map((line) => " " + line)); for (const section of options.sections) { const commands = options.commands.filter((command) => command.section === section && !command.hidden); if (!commands.length) continue; - lines.push("", section + ":"); + lines.push("", theme.iceBlue(section + ":")); const usages = commands.map((command) => { const aliases = command.aliases?.length ? ` (${command.aliases.map((alias) => options.prefix + alias).join(", ")})` : ""; - return `${options.prefix}${command.name}${command.args ? " " + command.args : ""}${aliases}`; + return usageOf(command, options.prefix) + aliases; }); - const width = Math.min(42, Math.max(...usages.map((usage) => usage.length)) + 2); - commands.forEach((command, index) => lines.push(` ${usages[index]!.padEnd(width)}${command.summary}`)); + // Was capped at 42 columns: any usage longer than that then butted straight + // against its own summary with no separating space. Pad to the widest usage + // actually present, so the summary column is straight and never collides. + const width = Math.max(...usages.map((usage) => visibleWidth(usage))) + 2; + commands.forEach((command, index) => + lines.push(` ${padCol(theme.cyan(usages[index]!), width)}${theme.dim(command.summary)}`), + ); } - if (options.footer?.length) lines.push("", ...options.footer); + if (options.footer?.length) lines.push("", ...options.footer.map((line) => theme.dim(line))); return lines.join("\n") + "\n"; } +/** + * `help `. Three outcomes, in order: + * + * 1. exact name or alias -> full detail for that command + * 2. substring of a name or summary -> the commands that matched + * 3. nothing -> unknown, plus the closest did-you-mean + * + * Step 2 is the addition. Previously anything that was not an exact name was + * "Unknown command", so `help vault` or `help git` — a user describing what they + * want rather than naming it — hit a dead end standing next to a registry that + * could have answered. Search runs AFTER exact match, so a real command name + * always wins, and falls through to did-you-mean when it finds nothing. + */ +function renderTargetHelp(options: HelpOptions, target: string): string { + const command = findRegisteredCommand(options.commands, target); + if (command) { + const aliases = command.aliases?.length + ? `\nAliases: ${command.aliases.map((alias) => options.prefix + alias).join(", ")}` + : ""; + // Neighbours in the same section: the answer to "what else is near this?", + // which is usually the next question after reading one command's detail. + const siblings = options.commands + .filter((c) => c.section === command.section && c.name !== command.name && !c.hidden) + .map((c) => options.prefix + c.name); + const related = siblings.length ? `\n${theme.dim("Related: " + siblings.join(" "))}` : ""; + return ( + `Usage: ${theme.bold(usageOf(command, options.prefix))}\n` + + `${command.summary}\n` + + `Section: ${command.section}${aliases}${related}\n` + ); + } + + const needle = target.toLowerCase(); + const matches = options.commands.filter( + (c) => + !c.hidden && + (c.name.includes(needle) || + c.summary.toLowerCase().includes(needle) || + c.section.toLowerCase().includes(needle) || + c.aliases?.some((a) => a.includes(needle))), + ); + if (matches.length) { + const usages = matches.map((c) => usageOf(c, options.prefix)); + const width = Math.max(...usages.map((u) => visibleWidth(u))) + 2; + const rows = matches.map( + (c, i) => ` ${padCol(theme.cyan(usages[i]!), width)}${theme.dim(c.summary)}`, + ); + const header = `No command named ${options.prefix}${target} — ${matches.length} match${matches.length === 1 ? "" : "es"}:`; + return [header, "", ...rows, "", theme.dim(`${options.prefix}help for detail.`)].join("\n") + "\n"; + } + + const suggestion = suggestRegisteredCommand(target, commandNames(options.commands)); + return `Unknown command: ${options.prefix}${target}${suggestion ? `\nDid you mean ${options.prefix}${suggestion}?` : ""}\n`; +} + function editDistance(a: string, b: string, max: number): number { if (Math.abs(a.length - b.length) > max) return max + 1; let previous = Array.from({ length: b.length + 1 }, (_, index) => index); diff --git a/src/ui/effort.ts b/src/ui/effort.ts index 1e25ebe..a8b3c03 100644 --- a/src/ui/effort.ts +++ b/src/ui/effort.ts @@ -6,9 +6,30 @@ import { theme } from "./theme.js"; import { KAOMOJI } from "./kaomoji.js"; -export const EFFORT_TIERS = ["LOW", "MED", "MAX", "ULTRA", "CODEPRO"] as const; +// Must stay identical to the orchestrator's closed set — +// lib/orchestrator/presets/contracts.py:EFFORT_TIERS. HIGH was missing here, so +// the dial could not reach a tier the backend accepts and no CLI user could +// select it. Names are what travel on the wire; the 1-based numeric shortcut is +// a local convenience, which is why adding HIGH shifts /effort 5 from CODEPRO to +// ULTRA. The slider echoes the resolved tier on every set, so a mis-numbered +// entry is visible immediately. +export const EFFORT_TIERS = ["LOW", "MED", "HIGH", "MAX", "ULTRA", "CODEPRO"] as const; export type EffortTier = (typeof EFFORT_TIERS)[number]; +/** + * What the dial actually moves, in the orchestrator's own terms + * (lib/orchestrator/presets/effort.py:EffectiveEffortPolicy). An effort profile + * may only REDUCE these envelopes, or re-bind planner/synthesizer/arbiter to + * models the preset already allows — it never grants new capability. Kept + * deliberately concrete: "more effort = better" tells a user nothing about what + * they are spending. + */ +export const EFFORT_LEGEND = "phases · sub-agent fan-out · repair passes · UVT ceiling"; + +/** CODEPRO alone flips System-2 review (workflow_engine.py:94) and unlimited + * context (deep_thinking.py:26) — a capability change, not just a bigger budget. */ +const CODEPRO_LEGEND = "+ System-2 review · unlimited context"; + /** Accepts a tier name (any case) or a 1-based index; null if unrecognized. * Coerces defensively: a hand-edited config can carry a number or null. */ export function normalizeEffort(input: string): EffortTier | null { @@ -55,7 +76,12 @@ export function renderEffortSlider(current: string): string[] { labels += i === idx && tier ? theme.bold(theme.cyan(seg)) : theme.dim(seg); }); - return [title, track, labels]; + // Fourth line: what moving the dial actually buys. Without it the slider is a + // pretty control with no stated units — a user can see they are at MAX and + // still not know what MAX costs or grants. + const legend = " " + theme.dim(pro ? `${EFFORT_LEGEND} ${CODEPRO_LEGEND}` : EFFORT_LEGEND); + + return [title, track, labels, legend]; } // Block-letter CODEPRO — kept ≤ 80 cols, no combining chars (width-safe). diff --git a/test/effort.test.ts b/test/effort.test.ts index 4c3f479..e83f881 100644 --- a/test/effort.test.ts +++ b/test/effort.test.ts @@ -33,20 +33,35 @@ test("normalizeEffort accepts names (any case) and 1-based indexes", () => { assert.equal(normalizeEffort("CodePro"), "CODEPRO"); assert.equal(normalizeEffort(" ultra "), "ULTRA"); assert.equal(normalizeEffort("1"), "LOW"); - assert.equal(normalizeEffort("5"), "CODEPRO"); + // HIGH was added at index 3 to match the orchestrator's closed tier set + // (lib/orchestrator/presets/contracts.py), so the numeric shortcuts shifted: + // the top of the dial is now 6, and 5 is ULTRA. + assert.equal(normalizeEffort("3"), "HIGH"); + assert.equal(normalizeEffort("5"), "ULTRA"); + assert.equal(normalizeEffort("6"), "CODEPRO"); assert.equal(normalizeEffort("0"), null); - assert.equal(normalizeEffort("6"), null); + assert.equal(normalizeEffort("7"), null); assert.equal(normalizeEffort("zzz"), null); assert.equal(normalizeEffort(""), null); }); test("effort slider marks the current tier and shows the whole scale", () => { const s = renderEffortSlider("MAX").map(stripAnsi); - assert.equal(s.length, 3); + // Four lines now: title, track, labels, and a legend naming what the dial + // actually moves. The slider showed a position but never its units. + assert.equal(s.length, 4); assert.ok(s[0]?.includes("effort ▸ MAX")); assert.ok(s[1]?.includes("●")); assert.ok(s[1]?.includes("▓") && s[1]?.includes("░")); for (const tier of EFFORT_TIERS) assert.ok(s[2]?.includes(tier), `labels row missing ${tier}`); + assert.ok(s[3]?.includes("phases"), "legend should name what the dial controls"); +}); + +test("CODEPRO's legend names the capabilities only it unlocks", () => { + const legend = stripAnsi(renderEffortSlider("CODEPRO")[3] ?? ""); + assert.match(legend, /System-2/); + // Every other tier is a budget change, so it must NOT claim those. + assert.doesNotMatch(stripAnsi(renderEffortSlider("MAX")[3] ?? ""), /System-2/); }); test("CODEPRO fills the track and swaps the knob for lightning", () => { diff --git a/test/slash_registry.test.ts b/test/slash_registry.test.ts index e742a7f..2210fc4 100644 --- a/test/slash_registry.test.ts +++ b/test/slash_registry.test.ts @@ -10,6 +10,7 @@ import { completeSlash, suggestCommand, } from "../src/commands/slash_registry.js"; +import { EFFORT_TIERS } from "../src/ui/effort.js"; const here = dirname(fileURLToPath(import.meta.url)); @@ -74,9 +75,14 @@ test("suggestCommand catches close typos, rejects garbage", () => { // /effort (LOW..CODEPRO dial that drives `aether code` runs) has no origin/main // counterpart — pin its registry entry so a future registry rewrite can't // silently drop the only place this command is discoverable from. -test("effort is registered in the Session section with a tier|1-5 arg hint", () => { +test("effort is registered in the Session section with a tier|1-6 arg hint", () => { const c = findCommand("effort"); assert.ok(c, "/effort missing from registry"); assert.equal(c!.section, "Session"); - assert.equal(c!.args, "[tier|1-5]"); + // 1-6, not 1-5: HIGH was added so the dial covers the orchestrator's closed + // tier set (lib/orchestrator/presets/contracts.py). Tied to EFFORT_TIERS.length + // as well as the literal, so the hint can never again advertise a range the + // dial does not accept — which is exactly how it went stale. + assert.equal(c!.args, "[tier|1-6]"); + assert.equal(c!.args, `[tier|1-${EFFORT_TIERS.length}]`); });