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
35 changes: 11 additions & 24 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,18 @@ export function parseGoalCommand(commandName: string, defaultCommandName: string
const tokensResult = splitCommandLine(rawArguments);
if (tokensResult.ok === false) return tokensResult;
const tokens = tokensResult.value;
const normalizedCommandName = normalizeGoalCommandName(commandName, defaultCommandName);

if (normalizedCommandName === `${defaultCommandName}-status`) return okCommand("status");
if (normalizedCommandName === `${defaultCommandName}-list`) return okCommand("list");
if (normalizedCommandName === `${defaultCommandName}-pause`) return okCommand("pause", { reason: rawArguments });
if (normalizedCommandName === `${defaultCommandName}-resume`) return okCommand("resume");
if (normalizedCommandName === `${defaultCommandName}-clear`) return okCommand("clear");
if (normalizedCommandName === `${defaultCommandName}-abort`) return okCommand("abort", { reason: rawArguments });
if (normalizedCommandName === `${defaultCommandName}-focus`) return okCommand("focus", { goalId: rawArguments.trim() });
if (normalizedCommandName === `${defaultCommandName}-tweak`) return okCommand("tweak", { objective: rawArguments.trim() });
if (normalizedCommandName === `${defaultCommandName}-confirm`) return okCommand("confirm", { goalId: rawArguments.trim() });
if (normalizedCommandName === `${defaultCommandName}-reject`) return okCommand("reject", { goalId: rawArguments.trim() });
if (normalizedCommandName === `${defaultCommandName}-set`) return parseStart(tokens);
if (commandName === `${defaultCommandName}-status`) return okCommand("status");
if (commandName === `${defaultCommandName}-list`) return okCommand("list");
if (commandName === `${defaultCommandName}-pause`) return okCommand("pause", { reason: rawArguments });
if (commandName === `${defaultCommandName}-resume`) return okCommand("resume");
if (commandName === `${defaultCommandName}-clear`) return okCommand("clear");
if (commandName === `${defaultCommandName}-abort`) return okCommand("abort", { reason: rawArguments });
if (commandName === `${defaultCommandName}-focus`) return okCommand("focus", { goalId: rawArguments.trim() });
if (commandName === `${defaultCommandName}-tweak`) return okCommand("tweak", { objective: rawArguments.trim() });
if (commandName === `${defaultCommandName}-confirm`) return okCommand("confirm", { goalId: rawArguments.trim() });
if (commandName === `${defaultCommandName}-reject`) return okCommand("reject", { goalId: rawArguments.trim() });
if (commandName === `${defaultCommandName}-set`) return parseStart(tokens);

if (tokens.length === 0) return okCommand("status");
const first = tokens[0];
Expand All @@ -74,18 +73,6 @@ export function parseGoalCommand(commandName: string, defaultCommandName: string
return parseDraft(tokens);
}

export function goalCommandAliases(commandName: string): string[] {
return commandName.endsWith("s") ? [] : [`${commandName}s`];
}

function normalizeGoalCommandName(commandName: string, defaultCommandName: string): string {
for (const alias of goalCommandAliases(defaultCommandName)) {
if (commandName === alias) return defaultCommandName;
if (commandName.startsWith(`${alias}-`)) return `${defaultCommandName}${commandName.slice(alias.length)}`;
}
return commandName;
}

function parseDraft(tokens: string[]): OperationResult<ParsedGoalCommand> {
const parsed = parseGoalDefinition(tokens, "draft");
if (parsed.ok === false) return parsed;
Expand Down
33 changes: 15 additions & 18 deletions src/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { tool, type Config, type Hooks, type PluginInput } from "@opencode-ai/plugin";
import { runCompletionAudit } from "./audit";
import { goalCommandAliases, parseGoalCommand } from "./commands";
import { parseGoalCommand } from "./commands";
import { createGoalDraft, findSessionDraft, formatGoalDraftConfirmation, formatGoalDraftHeaderLines, removeDraft, upsertDraft } from "./draft";
import { DEFAULT_OPTIONS, MEANINGFUL_PROGRESS_TOOLS, PLUGIN_NAME } from "./defaults";
import { errorMessage } from "./errors";
Expand Down Expand Up @@ -203,37 +203,35 @@ export class GoalRuntime {
private registerCommands(config: Config): void {
if (config.command === undefined) config.command = {};
const commandName = this.options.commandName;
this.registerCommandAliases(config, commandName, {
this.registerCommand(config, commandName, {
description: "Draft a durable opencode-goal-x objective for explicit confirmation, or manage existing goals.",
template: "$ARGUMENTS",
});
this.registerCommandAliases(config, `${commandName}-set`, {
this.registerCommand(config, `${commandName}-set`, {
description: "Start a durable opencode-goal-x objective immediately.",
template: "$ARGUMENTS",
});
this.registerCommandAliases(config, `${commandName}-confirm`, {
this.registerCommand(config, `${commandName}-confirm`, {
description: "Confirm the latest drafted opencode-goal-x objective and start it.",
template: "$ARGUMENTS",
});
this.registerCommandAliases(config, `${commandName}-reject`, {
this.registerCommand(config, `${commandName}-reject`, {
description: "Discard the latest drafted opencode-goal-x objective without creating a goal.",
template: "$ARGUMENTS",
});
this.registerCommandAliases(config, `${commandName}-status`, { description: "Show the focused goal status.", template: "$ARGUMENTS" });
this.registerCommandAliases(config, `${commandName}-list`, { description: "List open goals.", template: "$ARGUMENTS" });
this.registerCommandAliases(config, `${commandName}-focus`, { description: "Focus an open goal by number or id.", template: "$ARGUMENTS" });
this.registerCommandAliases(config, `${commandName}-pause`, { description: "Pause the focused goal.", template: "$ARGUMENTS" });
this.registerCommandAliases(config, `${commandName}-resume`, { description: "Resume the focused paused goal.", template: "$ARGUMENTS" });
this.registerCommandAliases(config, `${commandName}-tweak`, { description: "Revise the focused goal objective.", template: "$ARGUMENTS" });
this.registerCommandAliases(config, `${commandName}-abort`, { description: "Abort and archive the focused goal.", template: "$ARGUMENTS" });
this.registerCommandAliases(config, `${commandName}-clear`, { description: "Clear and archive the focused goal.", template: "$ARGUMENTS" });
this.registerCommand(config, `${commandName}-status`, { description: "Show the focused goal status.", template: "$ARGUMENTS" });
this.registerCommand(config, `${commandName}-list`, { description: "List open goals.", template: "$ARGUMENTS" });
this.registerCommand(config, `${commandName}-focus`, { description: "Focus an open goal by number or id.", template: "$ARGUMENTS" });
this.registerCommand(config, `${commandName}-pause`, { description: "Pause the focused goal.", template: "$ARGUMENTS" });
this.registerCommand(config, `${commandName}-resume`, { description: "Resume the focused paused goal.", template: "$ARGUMENTS" });
this.registerCommand(config, `${commandName}-tweak`, { description: "Revise the focused goal objective.", template: "$ARGUMENTS" });
this.registerCommand(config, `${commandName}-abort`, { description: "Abort and archive the focused goal.", template: "$ARGUMENTS" });
this.registerCommand(config, `${commandName}-clear`, { description: "Clear and archive the focused goal.", template: "$ARGUMENTS" });
}

private registerCommandAliases(config: Config, command: string, definition: NonNullable<Config["command"]>[string]): void {
private registerCommand(config: Config, command: string, definition: NonNullable<Config["command"]>[string]): void {
if (config.command === undefined) config.command = {};
config.command[command] = definition;
const suffix = command.slice(this.options.commandName.length);
for (const alias of goalCommandAliases(this.options.commandName)) config.command[`${alias}${suffix}`] = definition;
}

private async handleCommand(command: string, sessionID: string, rawArguments: string, parts: MutableTextPart[]): Promise<void> {
Expand Down Expand Up @@ -286,8 +284,7 @@ export class GoalRuntime {

private ownsCommand(command: string): boolean {
const base = this.options.commandName;
if (command === base || command.startsWith(`${base}-`)) return true;
return goalCommandAliases(base).some((alias) => command === alias || command.startsWith(`${alias}-`));
return command === base || command.startsWith(`${base}-`);
}

private replaceCommandText(parts: MutableTextPart[], text: string): void {
Expand Down
15 changes: 7 additions & 8 deletions test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,13 @@ describe("goal command parser", () => {
expect(command.budgetOverrides.maxTurns).toBe(2);
});

test("parses plural /goals aliases like /goal", () => {
const draft = requireParseSuccess(parseGoalCommand("goals", "goal", "draft with plural alias"));
const confirm = requireParseSuccess(parseGoalCommand("goals-confirm", "goal", "draft-1"));

expect(draft.action).toBe("draft");
expect(draft.objective).toBe("draft with plural alias");
expect(confirm.action).toBe("confirm");
expect(confirm.goalId).toBe("draft-1");
test("does not normalize plural lifecycle command aliases", () => {
const parsed = parseGoalCommand("goals-confirm", "goal", "draft-1");

const command = requireParseSuccess(parsed);
expect(command.action).toBe("draft");
expect(command.objective).toBe("draft-1");
expect(command.goalId).toBeUndefined();
});

test("maps draft confirmation commands", () => {
Expand Down
28 changes: 19 additions & 9 deletions test/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,27 +45,37 @@ describe("GoalRuntime integration-style behavior", () => {
await setup.runtime.disposeForTest();
});

test("plural /goals alias is registered and creates a confirmable draft flow", async () => {
test("default command registration and ownership are singular-only", async () => {
const setup = createRuntimeSetup({ requireAudit: false });
const commandHook = requireCommandHook(setup.hooks);
const configHook = requireConfigHook(setup.hooks);
const config: Config = {};
const parts = textParts();

await configHook(config);
expect(config.command?.goals).toBeDefined();
expect(config.command?.["goals-confirm"]).toBeDefined();
expect(config.command?.goal).toBeDefined();
expect(config.command?.["goal-confirm"]).toBeDefined();
expect(config.command?.goals).toBeUndefined();
expect(config.command?.["goals-confirm"]).toBeUndefined();

await commandHook({ command: "goals", sessionID: "s1", arguments: "draft from plural alias" }, { parts });
const afterDraft = loadStore(setup.paths);
expect(firstDraft(afterDraft).status).toBe("planning");
const pluralDraftParts = textParts();
await commandHook({ command: "goals", sessionID: "s1", arguments: "draft from plural alias" }, { parts: pluralDraftParts });
const afterPluralDraft = loadStore(setup.paths);
expect(Object.values(afterPluralDraft.drafts ?? {})).toHaveLength(0);
expect(firstText(pluralDraftParts)).toBe("");

const proposeDraft = requireTool(setup.hooks, "propose_goal_draft");
await proposeDraft.execute({ objective: "draft from plural alias" }, toolContext(setup.directory));
await commandHook({ command: "goals-confirm", sessionID: "s1", arguments: "" }, { parts });
const pluralConfirmParts = textParts();
await commandHook({ command: "goals-confirm", sessionID: "s1", arguments: "" }, { parts: pluralConfirmParts });
const afterPluralConfirm = loadStore(setup.paths);
expect(firstText(pluralConfirmParts)).toBe("");
expect(afterPluralConfirm.goals).toHaveLength(0);

const singularConfirmParts = textParts();
await commandHook({ command: "goal-confirm", sessionID: "s1", arguments: "" }, { parts: singularConfirmParts });

const store = loadStore(setup.paths);
expect(firstText(parts)).toContain("Goal draft confirmed");
expect(firstText(singularConfirmParts)).toContain("Goal draft confirmed");
expect(store.goals).toHaveLength(1);
await setup.runtime.disposeForTest();
});
Expand Down
Loading