From 1fb3b84f541d0e6c76160a882fdc0369b5205209 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Mon, 27 Jul 2026 18:14:24 -0400 Subject: [PATCH 1/2] feat(cli): make /models, /help and /effort say what they mean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three surfaces a user reads constantly, each losing information a different way. /models — rows were tab-separated with a marker column mixing "*" (one column) with a lock emoji (two). Any id crossing a tab stop shifted every following column, so the lock — the one thing the eye needs to be steady — landed somewhere different on each row. Columns are now padded by VISIBLE width via ui/text.visibleWidth, grouped into MODELS and ORCHESTRATORS, and a locked row states the tier that unlocks it instead of only showing a padlock. --json output is unchanged. /help — renderRegistryHelp emitted flat, unstyled text, and any target that was not an exact command name returned "Unknown command", so `help vault` or `help git` (a user describing what they want rather than naming it) hit a dead end next to a registry that could have answered. Adds a substring search over names, summaries, sections and aliases, running AFTER exact match and BEFORE did-you-mean so both existing paths are untouched. Command detail now lists sibling commands in the same section. Section rows are styled, and the usage column pads to the widest entry present rather than a hard 42 columns, which previously let a long usage butt straight against its own summary. The slash footer read "for detail ? Tab completes" — a mangled "·" that shipped as a question mark and parsed as part of the sentence. /effort — the dial was missing HIGH. The orchestrator's closed tier set is LOW/MED/HIGH/MAX/ULTRA/CODEPRO (lib/orchestrator/presets/contracts.py), and anything outside it is silently coerced to MED, so the CLI simply could not reach a tier the backend accepts. Adding it shifts the numeric shortcuts: the top of the dial is 6, and /effort 5 is now ULTRA rather than CODEPRO. Names are what travel on the wire and the slider echoes the resolved tier on every set, so a mis-numbered entry is visible immediately. The slider also gained a legend naming what the dial moves — phases, sub-agent fan-out, repair passes, UVT ceiling — taken from EffectiveEffortPolicy rather than invented, plus the two capabilities CODEPRO alone unlocks (System-2 review, unlimited context). It showed a position with no units before. Tests: 822 pass. The one failure, migration_guard's @types/node pin, is pre-existing — package.json is not in this diff and its last commit is HEAD (the dependabot bump that broke it). The registry arg-hint test is now tied to EFFORT_TIERS.length so the hint can never again advertise a range the dial does not accept. Co-Authored-By: Claude Opus 5 --- COMMANDS.md | 6 +- src/commands/models.ts | 89 +++++++++++++++++++++++++---- src/commands/slash_help.ts | 4 +- src/commands/slash_registry.ts | 2 +- src/core/command_registry.ts | 100 +++++++++++++++++++++++++++------ src/ui/effort.ts | 30 +++++++++- test/effort.test.ts | 21 ++++++- test/slash_registry.test.ts | 10 +++- 8 files changed, 223 insertions(+), 39 deletions(-) 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/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}]`); }); From f1918e2d0e8162cf21433d16e1f4e329c43e9c50 Mon Sep 17 00:00:00 2001 From: dbarr5 Date: Mon, 27 Jul 2026 21:02:03 -0400 Subject: [PATCH 2/2] fix(deps): pin @types/node back to ^24 to match the declared engines floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI has been red on main since fc0fa41, which bumped @types/node to ^26.1.1. The bump contradicts the package's own contract: engines.node is ">=24", so typing the project against Node 26 lets tsc accept code using APIs that do not exist on the Node 24 this package claims to support — a type error that only shows up at a user's runtime. test/migration_guard.test.ts asserts exactly that pairing and is the thing that went red. The guard is correct and the dependency bump is not, so this restores the ^24.0.0 range that was in place before it. Unblocks the CI gate on this branch. Worth a dependabot ignore rule for @types/node majors so the same bump does not land again. Tests: 823 pass, 0 fail. Co-Authored-By: Claude Opus 5 --- package-lock.json | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) 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" } }