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
22 changes: 12 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ Use it when you want OpenCode to keep pursuing a concrete outcome without losing

## Quick Start

Add the server plugin target to your `opencode.json`:
Add the server plugin package to your `opencode.json`:

```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-goal-x/server"]
"plugin": ["opencode-goal-x"]
}
```

Use the package name in OpenCode config. `opencode-goal-x/server` is an exported module subpath, but OpenCode's npm plugin installer expects the installable package name.

Restart OpenCode. Plugins are loaded at startup, so running sessions will not see new plugin config until you quit and reopen OpenCode.

Start with a safe draft:
Expand All @@ -39,7 +41,7 @@ By default, state is stored in `.opencode/goals/` inside the current project. Us

## Optional TUI Target

The server plugin target, `opencode-goal-x/server`, owns commands, tools, lifecycle state, auto-continuation, todo sync, and audits. Load this target first for Goal X to work.
The server plugin package, `opencode-goal-x`, owns commands, tools, lifecycle state, auto-continuation, todo sync, and audits. Load this package for Goal X to work.

The TUI plugin target, `opencode-goal-x/tui`, is optional. Load it through OpenCode's TUI plugin mechanism when you want dashboard/status UI for the same `.opencode/goals/` files. It is target-exclusive and does not replace the server plugin.

Expand All @@ -58,10 +60,10 @@ The TUI plugin target, `opencode-goal-x/tui`, is optional. Load it through OpenC

## Package Targets

This package keeps the default export compatible with existing server-plugin loading and also exposes explicit OpenCode targets:
This package keeps the default export as the supported npm server-plugin entrypoint and also exposes explicit module subpaths:

- `opencode-goal-x` / `.`: server plugin compatibility export.
- `opencode-goal-x/server`: explicit server plugin target.
- `opencode-goal-x` / `.`: supported server plugin config target.
- `opencode-goal-x/server`: server plugin module subpath for direct import/module loaders; do not use this as the npm plugin install string in `opencode.json`.
- `opencode-goal-x/tui`: TUI plugin target.

Goal X is built directly on OpenCode server-plugin hooks, native sessions, command hooks, compaction hooks, custom tools, state files, and a target-exclusive TUI plugin module.
Expand All @@ -74,12 +76,12 @@ For the first npm publish, publish once manually, then configure npm Trusted Pub

## Troubleshooting

**Plugin not loading?** Check that `opencode.json` uses the server target in the `plugin` array, then fully quit and restart OpenCode. Config and plugin packages are loaded at startup, not hot-reloaded.
**Plugin not loading?** Check that `opencode.json` uses the package name in the `plugin` array, then fully quit and restart OpenCode. Config and plugin packages are loaded at startup, not hot-reloaded.

```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-goal-x/server"]
"plugin": ["opencode-goal-x"]
}
```

Expand Down Expand Up @@ -173,7 +175,7 @@ Configure through OpenCode's plugin tuple form:
"$schema": "https://opencode.ai/config.json",
"plugin": [
[
"opencode-goal-x/server",
"opencode-goal-x",
{
"maxTurns": 80,
"maxRuntimeMs": 28800000,
Expand Down Expand Up @@ -289,7 +291,7 @@ Manual OpenCode smoke tests before release:

## Known OpenCode API Limitations

- TUI plugins are a separate target; load `./server` and `./tui` through their respective OpenCode plugin mechanisms.
- TUI plugins are a separate target; load the server plugin as `opencode-goal-x` in `opencode.json` and load `./tui` through the TUI plugin mechanism.
- No Browser UI plugin target is implemented because no analogous public BUI plugin API is currently exposed.
- The server plugin host currently types its client through the legacy SDK path, while current OpenCode SDK v2 types include `SessionPromptData.body.variant`; Goal X builds variant-aware prompt bodies against the current v2 type and passes the same compatible payload through the server client.
- Strong read-only auditor isolation may still require an OpenCode agent/permission configuration outside this plugin; Goal X sends read-only-oriented tool policy and prompt constraints by default.
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