Browse Source

add session goal command

Alvin Unreal 3 months ago
parent
commit
50f79dbd35

+ 1 - 0
README.md

@@ -492,6 +492,7 @@ Use this section as a map: start with installation, then jump to features, confi
 | **[Council](docs/council.md)** | Run multiple models in parallel and synthesize a single answer with `@council` |
 | **[Multiplexer Integration](docs/multiplexer-integration.md)** | Watch agents work live in Tmux or Zellij panes |
 | **[Session Management](docs/session-management.md)** | Reuse recent child-agent sessions with short aliases instead of starting over |
+| **[Session Goal](docs/session-goal.md)** | Pin a session objective with `/goal` so todos, delegation, and verification stay aligned |
 | **[Todo Continuation](docs/todo-continuation.md)** | Auto-continue orchestrator sessions with cooldowns and safety checks |
 | **[Preset Switching](docs/preset-switching.md)** | Switch agent model presets at runtime with `/preset` |
 | **[Subtask](docs/subtask.md)** | Run a bounded child worker with `/subtask` and return a structured summary to the main session |

+ 9 - 0
docs/interview.md

@@ -42,6 +42,15 @@ You can also resume by basename if it exists in the configured output folder:
 /interview kanban-design-tool
 ```
 
+Promote an interview spec into the current session goal:
+
+```text
+/goal from kanban-design-tool
+```
+
+This uses the interview title and `Current spec` section as the pinned goal, so
+todos, delegation, and verification stay aligned with the clarified spec.
+
 ## What the browser UI gives you
 
 - focused question flow instead of open-ended chat

+ 51 - 0
docs/session-goal.md

@@ -0,0 +1,51 @@
+# Session Goal
+
+`/goal` pins a session-scoped objective so long work keeps a clear north star.
+
+Use it when the task is bigger than one prompt and has a clear success condition,
+but you do not want a separate project-management system.
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `/goal <objective>` | Set the current session goal |
+| `/goal` | Show the active goal and how it relates to todos and auto-continuation |
+| `/goal clear` | Clear the current session goal |
+| `/goal from <interview>` | Set the goal from an existing interview markdown spec |
+
+Examples:
+
+```text
+/goal Add lightweight session goals. Done when UX, docs, and tests are complete.
+/goal from kanban-design-tool
+/goal clear
+```
+
+## How it fits with other features
+
+```text
+Interview → Goal → Todos → Auto-continuation → Delegation → Verify
+```
+
+- **Goal** is the why and definition of done.
+- **Todos** are the execution ledger.
+- **Auto-continuation** keeps executing unfinished todos when enabled.
+- **Interview** turns a rough idea into a markdown spec that can become a goal.
+- **Task/Subtask delegation** inherits the parent goal as context, while each
+  delegated prompt remains the bounded task.
+
+## Important behavior
+
+Goal does not run anything by itself. It only reminds the orchestrator and child
+sessions what the session is trying to achieve.
+
+Auto-continuation remains todo-driven:
+
+```text
+Goal alone never causes continuation.
+Only incomplete todos trigger auto-continuation.
+```
+
+This keeps the feature slim: one pinned objective, no dashboard, no second todo
+system, and no project-global state.

+ 3 - 0
docs/subtask.md

@@ -48,6 +48,9 @@ Keep the request narrow. A good subtask has a clear finish line.
 In tmux or zellij, the subtask appears like other child-agent work because it is
 a real child session. Existing depth limits and pane cleanup handling apply.
 
+If the parent session has an active [Session Goal](session-goal.md), the worker inherits
+it as context. The explicit subtask request still defines the worker's scope.
+
 ## Worker scope
 
 The worker prompt is intentionally bounded:

+ 4 - 0
docs/todo-continuation.md

@@ -2,6 +2,10 @@
 
 Auto-continue the orchestrator when it stops with incomplete todos. Opt-in only — nothing resumes automatically unless you enable it.
 
+If a [Session Goal](session-goal.md) is active, auto-continuation resumes under that
+goal, but the goal does not trigger continuation by itself. Only incomplete
+todos do.
+
 ## Controls
 
 | Tool / Command | Description |

+ 16 - 0
docs/tools.md

@@ -70,3 +70,19 @@ Includes Prettier, Biome, `gofmt`, `rustfmt`, `ruff`, and 20+ others.
 Auto-continue has its own guide now:
 
 - [Todo Continuation](todo-continuation.md) — controls, safety gates, behavior, and config
+
+---
+
+## Session Goal
+
+Pin a session-scoped objective that keeps planning, todos, delegation, and
+verification aligned.
+
+| Command | Description |
+|---------|-------------|
+| `/goal <objective>` | Set the current session goal |
+| `/goal` | Show the active goal |
+| `/goal clear` | Clear the active goal |
+| `/goal from <interview>` | Promote an interview markdown spec into the active goal |
+
+See [Session Goal](session-goal.md) for the full workflow.

+ 1 - 0
src/hooks/index.ts

@@ -12,5 +12,6 @@ export { processImageAttachments } from './image-hook';
 export { createJsonErrorRecoveryHook } from './json-error-recovery';
 export { createPhaseReminderHook } from './phase-reminder';
 export { createPostFileToolNudgeHook } from './post-file-tool-nudge';
+export { createSessionGoalHook } from './session-goal';
 export { createTaskSessionManagerHook } from './task-session-manager';
 export { createTodoContinuationHook } from './todo-continuation';

+ 201 - 0
src/hooks/session-goal/index.test.ts

@@ -0,0 +1,201 @@
+import { describe, expect, test } from 'bun:test';
+import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { createSessionGoalHook } from './index';
+
+function createHook(directory = '.') {
+  return createSessionGoalHook(
+    { directory } as Parameters<typeof createSessionGoalHook>[0],
+    { interview: { outputFolder: 'interview' } } as Parameters<
+      typeof createSessionGoalHook
+    >[1],
+    { getAgentName: () => 'orchestrator' },
+  );
+}
+
+describe('createSessionGoalHook', () => {
+  test('sets and shows a manual session goal', async () => {
+    const hook = createHook();
+    const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+    await hook.handleCommandExecuteBefore(
+      {
+        command: 'goal',
+        sessionID: 'ses_1',
+        arguments: 'Ship the goal feature. Done when tests pass.',
+      },
+      output,
+    );
+
+    expect(output.parts[0].text).toContain('Set active goal:');
+    expect(hook.getGoal('ses_1')?.text).toBe(
+      'Ship the goal feature. Done when tests pass.',
+    );
+
+    const showOutput = { parts: [] as Array<{ type: string; text?: string }> };
+    await hook.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 'ses_1', arguments: '' },
+      showOutput,
+    );
+
+    expect(showOutput.parts[0].text).toContain('Active goal:');
+    expect(showOutput.parts[0].text).toContain('Auto-continuation');
+  });
+
+  test('injects active goal into orchestrator system prompt', async () => {
+    const hook = createHook();
+    await hook.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 'ses_1', arguments: 'Stay on target.' },
+      { parts: [] },
+    );
+    const output = { system: ['base prompt'] };
+
+    hook.handleSystemTransform({ sessionID: 'ses_1' }, output);
+
+    expect(output.system.join('\n')).toContain('<active_goal>');
+    expect(output.system.join('\n')).toContain('Stay on target.');
+    expect(output.system.join('\n')).toContain(
+      'Use todos as the execution ledger',
+    );
+  });
+
+  test('inherits parent goal for child sessions', async () => {
+    const hook = createSessionGoalHook(
+      { directory: '.' } as Parameters<typeof createSessionGoalHook>[0],
+      {} as Parameters<typeof createSessionGoalHook>[1],
+      { getAgentName: () => 'explorer' },
+    );
+    await hook.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 'parent', arguments: 'Parent objective.' },
+      { parts: [] },
+    );
+
+    hook.handleEvent({
+      event: {
+        type: 'session.created',
+        properties: { info: { id: 'child', parentID: 'parent' } },
+      },
+    });
+
+    const output = { system: [] as string[] };
+    hook.handleSystemTransform({ sessionID: 'child' }, output);
+
+    expect(output.system.join('\n')).toContain('<parent_goal>');
+    expect(output.system.join('\n')).toContain('Parent objective.');
+    expect(output.system.join('\n')).toContain('bounded task');
+  });
+
+  test('child sessions resolve updated parent goal live', async () => {
+    const hook = createSessionGoalHook(
+      { directory: '.' } as Parameters<typeof createSessionGoalHook>[0],
+      {} as Parameters<typeof createSessionGoalHook>[1],
+      { getAgentName: () => 'explorer' },
+    );
+    await hook.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 'parent', arguments: 'Original.' },
+      { parts: [] },
+    );
+    hook.handleEvent({
+      event: {
+        type: 'session.created',
+        properties: { info: { id: 'child', parentID: 'parent' } },
+      },
+    });
+    await hook.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 'parent', arguments: 'Updated.' },
+      { parts: [] },
+    );
+
+    const output = { system: [] as string[] };
+    hook.handleSystemTransform({ sessionID: 'child' }, output);
+
+    expect(output.system.join('\n')).toContain('Updated.');
+    expect(output.system.join('\n')).not.toContain('Original.');
+  });
+
+  test('child sessions stop inheriting after parent goal is cleared', async () => {
+    const hook = createSessionGoalHook(
+      { directory: '.' } as Parameters<typeof createSessionGoalHook>[0],
+      {} as Parameters<typeof createSessionGoalHook>[1],
+      { getAgentName: () => 'explorer' },
+    );
+    await hook.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 'parent', arguments: 'Parent objective.' },
+      { parts: [] },
+    );
+    hook.handleEvent({
+      event: {
+        type: 'session.created',
+        properties: { info: { id: 'child', parentID: 'parent' } },
+      },
+    });
+    await hook.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 'parent', arguments: 'clear' },
+      { parts: [] },
+    );
+
+    const output = { system: [] as string[] };
+    hook.handleSystemTransform({ sessionID: 'child' }, output);
+
+    expect(output.system).toEqual([]);
+    expect(hook.getGoal('child')).toBeUndefined();
+  });
+
+  test('sets goal from an interview document', async () => {
+    const directory = await mkdtemp(path.join(tmpdir(), 'goal-test-'));
+    const interviewDir = path.join(directory, 'interview');
+    await mkdir(interviewDir, { recursive: true });
+    await writeFile(
+      path.join(interviewDir, 'feature.md'),
+      [
+        '# Feature Goal',
+        '',
+        '## Current spec',
+        '',
+        'Build the feature with minimal scope.',
+        '',
+        '## Q&A history',
+        '',
+        'No answers yet.',
+      ].join('\n'),
+      'utf8',
+    );
+
+    const hook = createHook(directory);
+    const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+    await hook.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 'ses_1', arguments: 'from feature' },
+      output,
+    );
+
+    expect(output.parts[0].text).toContain('Set active goal from interview');
+    expect(hook.getGoal('ses_1')?.text).toContain('Feature Goal');
+    expect(hook.getGoal('ses_1')?.text).toContain(
+      'Build the feature with minimal scope.',
+    );
+  });
+
+  test('clears goals on command and session deletion', async () => {
+    const hook = createHook();
+    await hook.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 'ses_1', arguments: 'Temporary goal.' },
+      { parts: [] },
+    );
+    await hook.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 'ses_1', arguments: 'clear' },
+      { parts: [] },
+    );
+    expect(hook.getGoal('ses_1')).toBeUndefined();
+
+    await hook.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 'ses_1', arguments: 'Temporary goal.' },
+      { parts: [] },
+    );
+    hook.handleEvent({
+      event: { type: 'session.deleted', properties: { sessionID: 'ses_1' } },
+    });
+    expect(hook.getGoal('ses_1')).toBeUndefined();
+  });
+});

+ 229 - 0
src/hooks/session-goal/index.ts

@@ -0,0 +1,229 @@
+import * as fs from 'node:fs/promises';
+import type { PluginInput } from '@opencode-ai/plugin';
+import type { PluginConfig } from '../../config';
+import {
+  extractSummarySection,
+  extractTitle,
+  resolveExistingInterviewPath,
+} from '../../interview/document';
+import { createInternalAgentTextPart } from '../../utils';
+
+const COMMAND_NAME = 'goal';
+const MAX_GOAL_LENGTH = 4000;
+
+interface GoalState {
+  text: string;
+  source?: 'manual' | 'interview';
+  sourcePath?: string;
+  inheritedFrom?: string;
+  createdAt: number;
+}
+
+interface StoredGoalState extends GoalState {
+  inheritedFrom?: string;
+}
+
+interface SystemTransformOutput {
+  system: string[];
+}
+
+function normalizeGoalText(text: string): string {
+  return text.trim().replace(/\s+/g, ' ').slice(0, MAX_GOAL_LENGTH);
+}
+
+function pushText(
+  output: { parts: Array<{ type: string; text?: string }> },
+  text: string,
+) {
+  output.parts.push(createInternalAgentTextPart(text));
+}
+
+function formatGoal(state: GoalState, inherited: boolean): string {
+  const tag = inherited ? 'parent_goal' : 'active_goal';
+  const guidance = inherited
+    ? 'This is context only. Your delegated prompt remains the bounded task.'
+    : 'Use todos as the execution ledger. Keep planning, delegation, edits, and verification aligned to this goal. Do not broaden scope unless the user changes the goal.';
+  return `<${tag}>\nObjective: ${state.text}\n${guidance}\n</${tag}>`;
+}
+
+async function readInterviewGoal(
+  directory: string,
+  outputFolder: string,
+  value: string,
+): Promise<{ text: string; sourcePath: string } | null> {
+  try {
+    const sourcePath = resolveExistingInterviewPath(
+      directory,
+      outputFolder,
+      value,
+    );
+    if (!sourcePath) return null;
+
+    const content = await fs.readFile(sourcePath, 'utf8');
+    const title = extractTitle(content);
+    const summary = extractSummarySection(content);
+    const text = normalizeGoalText(
+      [title ? `From interview: ${title}` : '', summary]
+        .filter(Boolean)
+        .join('\n\n'),
+    );
+    return text ? { text, sourcePath } : null;
+  } catch {
+    return null;
+  }
+}
+
+function resolveGoal(
+  goals: Map<string, StoredGoalState>,
+  sessionID: string,
+): { goal: GoalState; inherited: boolean } | null {
+  const goal = goals.get(sessionID);
+  if (!goal) return null;
+  if (!goal.inheritedFrom) return { goal, inherited: false };
+
+  const parentGoal = goals.get(goal.inheritedFrom);
+  if (!parentGoal) {
+    goals.delete(sessionID);
+    return null;
+  }
+  return { goal: parentGoal, inherited: true };
+}
+
+export function createSessionGoalHook(
+  ctx: PluginInput,
+  config: PluginConfig,
+  options?: { getAgentName?: (sessionID: string) => string | undefined },
+): {
+  registerCommand: (config: Record<string, unknown>) => void;
+  handleCommandExecuteBefore: (
+    input: { command: string; sessionID: string; arguments: string },
+    output: { parts: Array<{ type: string; text?: string }> },
+  ) => Promise<void>;
+  handleEvent: (input: {
+    event: { type: string; properties?: Record<string, unknown> };
+  }) => void;
+  handleSystemTransform: (
+    input: { sessionID?: string },
+    output: SystemTransformOutput,
+  ) => void;
+  getGoal: (sessionID: string) => GoalState | undefined;
+} {
+  const goals = new Map<string, StoredGoalState>();
+  const outputFolder = config.interview?.outputFolder ?? 'interview';
+
+  return {
+    registerCommand: (opencodeConfig) => {
+      const commandConfig = opencodeConfig.command as
+        | Record<string, unknown>
+        | undefined;
+      if (commandConfig?.[COMMAND_NAME]) return;
+      if (!opencodeConfig.command) opencodeConfig.command = {};
+      (opencodeConfig.command as Record<string, unknown>)[COMMAND_NAME] = {
+        template: 'Set or show the current session goal',
+        description:
+          'Pin a session objective that keeps todos, delegation, and verification aligned',
+      };
+    },
+
+    handleCommandExecuteBefore: async (input, output) => {
+      if (input.command !== COMMAND_NAME) return;
+
+      output.parts.length = 0;
+
+      const args = input.arguments.trim();
+      if (!args) {
+        const resolved = resolveGoal(goals, input.sessionID);
+        pushText(
+          output,
+          resolved
+            ? `Active goal:\n${resolved.goal.text}\n\nUse todos for execution steps. Auto-continuation continues only while todos remain.`
+            : 'No active goal. Set one with /goal <objective>.',
+        );
+        return;
+      }
+
+      if (args === 'clear') {
+        goals.delete(input.sessionID);
+        pushText(output, 'Cleared the active goal for this session.');
+        return;
+      }
+
+      if (args.startsWith('from ')) {
+        const value = args.slice('from '.length).trim();
+        const interviewGoal = await readInterviewGoal(
+          ctx.directory,
+          outputFolder,
+          value,
+        );
+        if (!interviewGoal) {
+          pushText(
+            output,
+            `Could not find a readable interview spec for "${value}".`,
+          );
+          return;
+        }
+        goals.set(input.sessionID, {
+          text: interviewGoal.text,
+          source: 'interview',
+          sourcePath: interviewGoal.sourcePath,
+          createdAt: Date.now(),
+        });
+        pushText(
+          output,
+          `Set active goal from interview:\n${interviewGoal.text}`,
+        );
+        return;
+      }
+
+      const text = normalizeGoalText(args);
+      goals.set(input.sessionID, {
+        text,
+        source: 'manual',
+        createdAt: Date.now(),
+      });
+      pushText(output, `Set active goal:\n${text}`);
+    },
+
+    handleEvent: (input) => {
+      const event = input.event;
+      if (event.type === 'session.created') {
+        const info = event.properties?.info as
+          | { id?: string; parentID?: string }
+          | undefined;
+        if (!info?.id || !info.parentID) return;
+        const parentGoal = goals.get(info.parentID);
+        if (!parentGoal) return;
+        goals.set(info.id, {
+          inheritedFrom: info.parentID,
+          createdAt: Date.now(),
+          text: '',
+        });
+        return;
+      }
+
+      if (event.type === 'session.deleted') {
+        const props = event.properties as
+          | { info?: { id?: string }; sessionID?: string }
+          | undefined;
+        const sessionID = props?.info?.id ?? props?.sessionID;
+        if (sessionID) goals.delete(sessionID);
+      }
+    },
+
+    handleSystemTransform: (input, output) => {
+      if (!input.sessionID) return;
+      const resolved = resolveGoal(goals, input.sessionID);
+      if (!resolved) return;
+
+      const agentName = options?.getAgentName?.(input.sessionID);
+      const { goal, inherited } = resolved;
+      if (!inherited && agentName && agentName !== 'orchestrator') return;
+
+      const block = formatGoal(goal, inherited);
+      if (output.system.some((entry) => entry.includes(block))) return;
+      output.system.push(block);
+    },
+
+    getGoal: (sessionID) => resolveGoal(goals, sessionID)?.goal,
+  };
+}

+ 23 - 0
src/index.ts

@@ -25,6 +25,7 @@ import {
   createJsonErrorRecoveryHook,
   createPhaseReminderHook,
   createPostFileToolNudgeHook,
+  createSessionGoalHook,
   createTaskSessionManagerHook,
   createTodoContinuationHook,
   ForegroundFallbackManager,
@@ -137,6 +138,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let jsonErrorRecoveryHook: ReturnType<typeof createJsonErrorRecoveryHook>;
   let foregroundFallback: ForegroundFallbackManager;
   let todoContinuationHook: ReturnType<typeof createTodoContinuationHook>;
+  let sessionGoalHook: ReturnType<typeof createSessionGoalHook>;
   let taskSessionManagerHook: ReturnType<typeof createTaskSessionManagerHook>;
   let interviewManager: ReturnType<typeof createInterviewManager>;
   let presetManager: ReturnType<typeof createPresetManager>;
@@ -307,6 +309,9 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       autoEnable: config.todoContinuation?.autoEnable ?? false,
       autoEnableThreshold: config.todoContinuation?.autoEnableThreshold ?? 4,
     });
+    sessionGoalHook = createSessionGoalHook(ctx, config, {
+      getAgentName: (sessionID) => sessionAgentMap.get(sessionID),
+    });
     taskSessionManagerHook = createTaskSessionManagerHook(ctx, {
       maxSessionsPerAgent: config.sessionManager?.maxSessionsPerAgent ?? 2,
       readContextMinLines: config.sessionManager?.readContextMinLines ?? 10,
@@ -732,6 +737,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       }
 
       interviewManager.registerCommand(opencodeConfig);
+      sessionGoalHook.registerCommand(opencodeConfig);
       presetManager.registerCommand(opencodeConfig);
       subtaskCommandManager.registerCommand(opencodeConfig);
     },
@@ -784,6 +790,12 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       // Todo-continuation: auto-continue orchestrator on incomplete todos
       await todoContinuationHook.handleEvent(input);
 
+      sessionGoalHook.handleEvent(
+        input as {
+          event: { type: string; properties?: Record<string, unknown> };
+        },
+      );
+
       // Handle auto-update checking
       await autoUpdateChecker.event(input);
 
@@ -952,6 +964,15 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         },
         output as { parts: Array<{ type: string; text?: string }> },
       );
+
+      await sessionGoalHook.handleCommandExecuteBefore(
+        input as {
+          command: string;
+          sessionID: string;
+          arguments: string;
+        },
+        output as { parts: Array<{ type: string; text?: string }> },
+      );
     },
 
     'chat.headers': chatHeadersHook['chat.headers'],
@@ -1024,6 +1045,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         }
       }
 
+      sessionGoalHook.handleSystemTransform(input, output);
+
       // Collapse to single system message for provider compatibility.
       // Some providers (e.g. Qwen via VLLM/DashScope) reject multiple
       // system messages. Sub-hooks above may push additional entries; join