Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ aether # no args = interactive REPL

<!-- SLASH-COMMANDS:START -->
`help`, `auth`, `models`, `model`, `switch`, `agent`, `agents`, `tier`, `effort`, `audit`, `doctor`, `settings`,
`voice`, `preview`, `clear`, `exit`, `mcp`, `autonomous-execution`, `subagent-driven-execution`, `self-review`, `recon`, `plan`, `research`, `project-review`,
`code-review`, `writing-skills`, `writing-plans`, `shell-profile`, `shell-result`, `shell-reset`, `terminal`, `terminal-attach`, `terminal-stop`, `terminal-status`, `queue`, `steer`,
`btw`, `pin`, `drop`, `context`, `snapshot`, `limit`, `audit-receipt`, `rollback`, `logs-view`, `goal`, `goals`, `memory`,
`workflow`, `workflow-templates`, `workflow-template`, `vault`, `vault-context`, `vault-search`, `vault-recent`, `vault-project`, `vault-tag`, `vault-tree`, `delegate`, `tree`,
`broadcast`, `gather`, `scaffold`, `port`, `test-drive`, `bench`, `purge`, `stage-diff`, `review`, `ship`, `revert`, `photogen`,
`frame`, `re-frame`, `videogen`, `sequence`, `animate`, `re-cut`, `output`, `storyboard`, `add`, `hud`, `agent-create`, `browser`,
`ats`
`voice`, `preview`, `clear`, `exit`, `mcp`, `autonomous-execution`, `subagent-driven-execution`, `self-review`, `recon`, `skill`, `plan`, `research`,
`project-review`, `code-review`, `writing-skills`, `writing-plans`, `shell-profile`, `shell-result`, `shell-reset`, `terminal`, `terminal-attach`, `terminal-stop`, `terminal-status`, `queue`,
`steer`, `btw`, `pin`, `drop`, `context`, `snapshot`, `limit`, `audit-receipt`, `rollback`, `logs-view`, `goal`, `goals`,
`memory`, `workflow`, `workflow-templates`, `workflow-template`, `vault`, `vault-context`, `vault-search`, `vault-recent`, `vault-project`, `vault-tag`, `vault-tree`, `delegate`,
`tree`, `broadcast`, `gather`, `scaffold`, `port`, `test-drive`, `bench`, `purge`, `stage-diff`, `review`, `ship`, `revert`,
`photogen`, `frame`, `re-frame`, `videogen`, `sequence`, `animate`, `re-cut`, `output`, `storyboard`, `add`, `hud`, `agent-create`,
`browser`, `ats`
<!-- SLASH-COMMANDS:END -->

## Runtime capability requirements
Expand Down Expand Up @@ -514,6 +514,23 @@ skill's content digest, so editing a trusted skill revokes that trust until you
approve the new digest. A skill declaration narrows what the agent may do — it
never grants a tool the host would otherwise refuse.

In an idle interactive console using a local model, `/skill <qualified-id>
<task>` loads an installed, enabled skill for just that task. For example,
`/skill user/fix-ci Investigate the failing unit test`. The task suffix is
sent literally, including extra spaces, newlines, quotes, `/`, and `!`; it is
never dispatched as another console command. The run header shows the resolved
skill ID, content digest, context size, and effective host tool limits.
`/context` identifies the last admitted turn; `/context next <task>` previews
normal next-turn defaults, or the current `/skill` command if one is supplied.
History and the slash picker restore editable text,
so submitting an old `/skill` line resolves its current digest and trust again.

`/skill` refuses busy, queued, line-input, `--no-skills`, and server-executed
cloud chat use. Use `aether agent --skill <id> <task>` for a hosted host-executed
run. A malformed or future-version local skill settings or trust store is
never treated as an empty store; incompatible data is reported and left
untouched until repaired. Normal skill defaults resume after this turn.

### `aether capabilities [--available]` — what this build can actually do

Prints the capability contract: tools, their side-effect class, and the
Expand Down Expand Up @@ -623,6 +640,7 @@ Each starts an agent loop in the REPL.
| `/self-review` | Review your own recent work. |
| `/recon <topic>` | Deep reconnaissance pass over the codebase. |
| `/plan <topic>` | Write an implementation plan. |
| `/skill <qualified-id> <task>` | Use a trusted skill for one idle local task. |
| `/writing-plans <topic>` | Write a plan to `.hermes/plans/`. |
| `/research <topic>` | Research → gather → summarize. |
| `/project-review` | Full project review + summary. (Was `/review`; that name is now the change-review rail below.) |
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ Type `/` at an idle raw terminal prompt to browse described commands. Use
arrows or Tab to choose, Enter to insert editable command text, then Enter
again to run it; Escape restores your earlier draft.

On a local model, `/skill user/fix-ci Investigate the failing unit test` uses
that trusted skill for one idle task, then restores your normal skill defaults.
The task text stays literal; the run header shows the resolved skill digest and
host tool limits. Busy, queued, line-input, `--no-skills`, and server-executed
cloud chat invocations are refused. [Skill details](COMMANDS.md#aether-skills-subcommand--inspect-trust-and-manage-agent-skills).

Use `/pin src/guide.md` to attach that file's complete, bounded UTF-8 content
to each admitted coding turn. The file is read from the execution checkout, so
a worktree turn uses its own copy. `/context` shows what the last turn actually
Expand Down
8 changes: 7 additions & 1 deletion docs/generated/commands.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!-- GENERATED FILE: run `npm run docs:generate`; do not edit by hand. -->
<!-- manifest-digest: sha256:15c107ce5164a95e2383d4e4b82c4f8dee285d5942b384498ee7dedbb9b934d6 -->
<!-- manifest-digest: sha256:def6250fc8d0ac298249432af7f7bdf2286eb74cf4870321888702e04f9c33ea -->
# Generated command reference

This reference is generated from the validated, versioned command manifest. Availability is evaluated at runtime; a listed command may still require authentication, a hosted capability, or local tooling.
Expand Down Expand Up @@ -449,6 +449,12 @@ deep reconnaissance

Permission: `unknown` · Availability: `runtime-dependent` · Telemetry: `slash.recon`

#### `/skill <qualified-id> <task>`

use a trusted skill for one idle local task

Permission: `unknown` · Availability: `runtime-dependent` · Telemetry: `slash.skill`

#### `/plan <topic>`

write implementation plan
Expand Down
2 changes: 1 addition & 1 deletion scripts/generate-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const COMMAND_PLACEHOLDERS = new Set([
"command", "connect|status|disconnect", "connect|status|disconnect|pr|checks|ci|workflow|action", "desc|view|start|pause|resume|cancel|complete|note",
"element", "file", "guidance", "id", "id|all", "id|section", "lang", "login|status|token|refresh|logout",
"model", "msg", "n", "name", "neo|kronus", "note", "n|id", "order-id", "path", "prompt",
"q", "section", "subcommand", "tag", "tag|n|id", "target", "task", "title", "topic", "type", "uvt", "value",
"q", "qualified-id", "section", "subcommand", "tag", "tag|n|id", "target", "task", "title", "topic", "type", "uvt", "value",
]);

export function normalizeEol(value: string): string { return value.replace(/\r\n?/g, "\n"); }
Expand Down
47 changes: 43 additions & 4 deletions src/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ import type { WorkflowViewerState } from "../ui/workflow_viewer.js";
import type { StreamFrame } from "../core/stream.js";
import type { BrainEvent, ToolName } from "../core/brain_protocol.js";
import { ConsoleTaskContinuation, accountFingerprint, consoleWorkspaceState, observedWorkspaceChanges, type ModelTarget, type ObservedTool } from "./model_continuation.js";
import { parseOneTurnSkill } from "./one_turn_skill.js";

// Key decoding lives in ui/keys.ts (shared with pickers/viewers); re-exported
// here so existing imports keep working.
Expand All @@ -120,6 +121,8 @@ export interface TurnSkillOptions {
capability?: RunCapability;
explicitSkill?: string;
noSkills?: boolean;
/** /skill requires a host-executed route for the resolved policy. */
requireHostSkillEnforcement?: boolean;
/** Local console authority, never serialized to Cloud. */
exec?: ToolExecutor;
/** Host-observed results only; no model text or shell output. */
Expand Down Expand Up @@ -487,6 +490,12 @@ export async function runTurn(
if (skillOpts.capability === "planning" && backend === "cloud") {
throw new ChatTurnError("planning requires host-executed tools; this cloud chat route runs tools on the server. Use `aether agent --planning` or `aether agent --local --planning`.", undefined, false);
}
if (skillOpts.explicitSkill && skillOpts.noSkills) {
throw new ChatTurnError("/skill is unavailable while --no-skills is active.", undefined, false);
}
if (skillOpts.requireHostSkillEnforcement && backend === "cloud") {
throw new ChatTurnError("/skill requires host-executed tools; this cloud chat route runs tools on the server. Switch to a local model or use aether agent --skill <id> <task>.", undefined, false);
}
// The same seam `aether agent` uses (commands/code.ts). Opened per turn, not
// per session, because automatic skill selection reads THIS prompt — a turn
// that says "the CI is failing" should pull the CI skill and the next one
Expand Down Expand Up @@ -526,7 +535,7 @@ export async function runTurn(
// exactly the cases the header exists to report.
if (run.contextTokens > 0 || run.session.notices.length > 0 || run.hasWarnings) {
const header = run.headerLines.join("\n");
if (header !== lastTurnHeader) {
if (skillOpts.requireHostSkillEnforcement || header !== lastTurnHeader) {
lastTurnHeader = header;
for (const line of run.headerLines) process.stderr.write(errTheme.dim(" " + line) + "\n");
}
Expand Down Expand Up @@ -564,7 +573,7 @@ export async function runTurn(
return await runCloudTurn(ctx, brief, lifecycle, boundedSignal.signal, onFrame, onPulsePaint, deadlineAt, deadlineMs);
} catch (err) {
const outcome = finalizeThrownTurn(lifecycle, err, ctx.cfg.baseUrl);
if (err instanceof ChatTurnError) throw err;
if (err instanceof ChatTurnError) throw err.outcome ? err : new ChatTurnError(err.message, outcome, err.rendered);
throw attachTurnOutcome(err, outcome);
} finally {
clearTimeout(deadline);
Expand Down Expand Up @@ -1598,7 +1607,9 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
viewerOpen = false;
break;
}
}, redrawInput, { ...skillOpts, capability: input.capability ?? "coding", onToolResult: tool => toolResults.push(tool), steer: steerChannel });
}, redrawInput, { ...skillOpts, capability: input.capability ?? "coding",
...(input.oneTurnSkill ? { explicitSkill: input.oneTurnSkill.reference, requireHostSkillEnforcement: true } : {}),
onToolResult: tool => toolResults.push(tool), steer: steerChannel });
const afterChanges = observedWorkspaceChanges(ctx.flags.cwd);
continuation.recordTurn(text, toolResults, afterChanges.filter(change => !beforeChanges.includes(change)), outcome.state, !sharedShellResult && !authContinuation);
if (ctx.flags.json) process.stdout.write(turnOutcomeJson(outcome) + "\n");
Expand Down Expand Up @@ -1645,7 +1656,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
// commit() clears the submitted line before the request starts. Put it
// back only when the user has not typed ahead; otherwise preserve their
// newer draft and leave the failed submission in history for recall.
const recovered = sharedShellResult || authFailure ? buf.value : recoverSubmittedPrompt(text, buf.value);
const recovered = sharedShellResult || authFailure ? buf.value : recoverSubmittedPrompt(input.oneTurnSkill?.source ?? text, buf.value);
if (recovered !== buf.value) {
buf.clear();
buf.insert(recovered);
Expand Down Expand Up @@ -1817,6 +1828,29 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P
return;
}
const queuePrefix = /^\s*\/queue[ \t]+/.exec(raw);
const oneTurnSkill = parseOneTurnSkill(queuePrefix ? raw.slice(queuePrefix[0].length) : raw);
if (oneTurnSkill) {
if (queuePrefix || busy) {
process.stdout.write("\n/skill runs only from the idle composer and cannot be queued. Draft preserved.\n");
repaint(); return;
}
if (skillOpts.noSkills) {
process.stdout.write("\n/skill is unavailable while --no-skills is active. Draft preserved.\n");
repaint(); return;
}
if (oneTurnSkill.kind === "usage") {
process.stdout.write("\n" + oneTurnSkill.message + "\n");
repaint(); return;
}
remember(raw);
buf.commit(raw);
process.stdout.write("\n");
busy = true;
await runAndDrain(queue.allocate({ kind: "chat", text: oneTurnSkill.task,
oneTurnSkill: { reference: oneTurnSkill.reference, source: oneTurnSkill.source } }));
renderHudLine(); repaint();
return;
}
const input = classifyConsoleInput(queuePrefix ? raw.slice(queuePrefix[0].length) : raw);
const commit = (): void => {
if (input.kind === "chat") { remember(buf.value); buf.commit(buf.value); }
Expand Down Expand Up @@ -2400,6 +2434,11 @@ export async function replLines(ctx: AppContext, skillOpts: TurnSkillOptions = {
}
const queuePrefix = /^\s*\/queue[ \t]+/.exec(rawLine);
const line = queuePrefix ? rawLine.slice(queuePrefix[0].length) : rawLine;
if (parseOneTurnSkill(line)) {
process.stdout.write("/skill requires the idle interactive composer; line input is unchanged.\n");
if (p) process.stdout.write(p + consoleShell.prompt());
continue;
}
if (continuation.pending && !line.trim().startsWith("/switch")) {
process.stdout.write("Choose /switch continue, fresh, cancel, brief, or edit first; input was not sent.\n");
if (p) process.stdout.write(p + consoleShell.prompt());
Expand Down
40 changes: 40 additions & 0 deletions src/commands/command_manifest_data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3813,6 +3813,46 @@ export const COMMAND_MANIFEST_SOURCE: readonly CommandManifestEntry[] = [
"note": null
}
},
{
"key": "slash:skill",
"surface": "slash",
"name": "skill",
"aliases": [],
"compatibilityAliases": [],
"deprecatedAliases": [],
"args": "<qualified-id> <task>",
"summary": "use a trusted skill for one idle local task",
"detailedHelp": "/skill <qualified-id> <task>\nUse an installed, enabled and trusted skill for this one task. Requires an idle interactive composer and a local host-executed turn; --no-skills refuses it. The following task text is literal. The next turn resumes normal skill defaults.",
"section": "Agent Modes",
"hidden": false,
"permissionClass": "unknown",
"availability": {
"state": "runtime-dependent",
"capabilityRequirements": []
},
"telemetryName": "slash.skill",
"acceptedGlobalFlags": [],
"ownedFlags": {},
"handler": {
"id": "handler:slash:skill",
"kind": "host",
"module": "src/commands/slash.ts",
"symbol": "handleSlash"
},
"docs": {
"kind": "manifest",
"module": "src/commands/command_manifest_data.ts",
"symbol": "COMMAND_MANIFEST_SOURCE",
"target": "skill",
"usage": "/skill <qualified-id> <task>",
"visible": true,
"disposition": "generated"
},
"release": {
"disposition": "new",
"note": "One-turn trusted skill invocation in the idle local composer"
}
},
{
"key": "slash:plan",
"surface": "slash",
Expand Down
4 changes: 3 additions & 1 deletion src/commands/console_input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ export type ConsoleInput =
| { kind: "profile"; action: "list" | "status" | "use"; profile?: "cmd" | "powershell" }
| ShareInput
| { kind: "error"; message: string }
| { kind: "chat"; text: string; capability?: RunCapability }
| { kind: "chat"; text: string; capability?: RunCapability;
/** Only idle /skill admission can set this; queued edits never resolve a skill. */
oneTurnSkill?: { reference: string; source: string } }
| { kind: "empty" };

export function classifyConsoleInput(raw: string): ConsoleInput {
Expand Down
21 changes: 21 additions & 0 deletions src/commands/one_turn_skill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/** Parse only the command and skill reference. The task is immutable literal
* text after one required separator, including its remaining whitespace. */
export type OneTurnSkillCommand =
| { kind: "invoke"; reference: string; task: string; source: string }
| { kind: "usage"; message: string };

export function parseOneTurnSkill(raw: string): OneTurnSkillCommand | null {
const command = /^[ \t]*\/skill(?=\s|$)/.exec(raw);
if (!command) return null;
const rest = raw.slice(command[0].length);
const leading = /^[\s]+/.exec(rest)?.[0] ?? "";
const afterLeading = rest.slice(leading.length);
const reference = /^[^\s]+/.exec(afterLeading)?.[0] ?? "";
const afterReference = afterLeading.slice(reference.length);
if (!reference || !/^\s/.test(afterReference)) {
return { kind: "usage", message: "usage: /skill <qualified-id> <task>" };
}
const task = afterReference.slice(1);
if (!task.trim()) return { kind: "usage", message: "usage: /skill <qualified-id> <task>" };
return { kind: "invoke", reference, task, source: raw };
}
3 changes: 3 additions & 0 deletions src/commands/slash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,9 @@ export async function handleSlash(
case "code-review":
out.write(`/${cmd} is handled directly in the interactive REPL.\n`);
break;
case "skill":
out.write("/skill requires the idle interactive composer; it is unavailable in line input or other owners.\n");
break;
case "pin":
await pinSlash(ctx, out, arg, line);
break;
Expand Down
16 changes: 13 additions & 3 deletions src/commands/slash_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { AuditEntry } from "../core/audit.js";
import { openRunSession } from "../core/skills/run_session.js";
import { sanitizeForTransport } from "../core/skills/context_packet.js";
import { applyPromptMode } from "./prompt_modes.js";
import { parseOneTurnSkill } from "./one_turn_skill.js";
import type { AdmittedContext } from "../core/selected_context.js";

export interface ContextInspectorOptions {
Expand Down Expand Up @@ -78,20 +79,29 @@ export async function contextSlash(ctx: AppContext, out: Writable, arg: string,
if (command.startsWith("next ")) {
const rawTask = command.slice("next ".length);
if (!rawTask.trim()) { out.write("usage: /context next <task>\n"); return; }
const mode = applyPromptMode(rawTask);
const oneTurnSkill = parseOneTurnSkill(rawTask);
if (oneTurnSkill?.kind === "usage") { out.write(oneTurnSkill.message + "\n"); return; }
if (oneTurnSkill?.kind === "invoke" && options.noSkills) {
out.write("Next-draft preview refused: /skill is unavailable while --no-skills is active.\n"); return;
}
if (oneTurnSkill?.kind === "invoke" && options.backend === "cloud") {
out.write("Next-draft preview refused: /skill requires host-executed tools; switch to a local model.\n"); return;
}
const mode = oneTurnSkill ? { handled: false as const } : applyPromptMode(rawTask);
if (mode.error) { out.write(mode.error + "\n"); return; }
if (mode.capability === "planning" && options.backend === "cloud") {
out.write("Next-draft preview refused: /plan cannot run on server-executed cloud chat. Use aether agent --planning.\n");
return;
}
const prompt = mode.handled ? mode.prompt! : rawTask;
const prompt = oneTurnSkill?.kind === "invoke" ? oneTurnSkill.task : mode.handled ? mode.prompt! : rawTask;
const opened = openRunSession({
projectRoot: ctx.flags.cwd,
prompt,
selectedPins: registry.selectedPins(),
selectedFileTransport: options.backend === "cloud" ? "unsupported" : "host",
...(mode.capability ? { capability: mode.capability } : {}),
...(options.explicitSkill ? { explicitSkill: options.explicitSkill } : {}),
...(oneTurnSkill?.kind === "invoke" ? { explicitSkill: oneTurnSkill.reference }
: options.explicitSkill ? { explicitSkill: options.explicitSkill } : {}),
...(options.noSkills ? { noSkills: true } : {}),
allowIncompleteInstructionDiscovery: options.backend === "cloud",
});
Expand Down
Loading
Loading